rapitapir 0.1.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 (157) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +57 -0
  4. data/CHANGELOG.md +94 -0
  5. data/CLEANUP_SUMMARY.md +155 -0
  6. data/CONTRIBUTING.md +280 -0
  7. data/LICENSE +21 -0
  8. data/README.md +485 -0
  9. data/debug_hash.rb +20 -0
  10. data/docs/EXTENSION_COMPARISON.md +388 -0
  11. data/docs/SINATRA_EXTENSION.md +467 -0
  12. data/docs/archive/PHASE_1_2_COMPLETE.md +77 -0
  13. data/docs/archive/PHASE_1_3_COMPLETE.md +152 -0
  14. data/docs/archive/PHASE_2_1_OBSERVABILITY_COMPLETED.md +203 -0
  15. data/docs/archive/PHASE_2_SUMMARY.md +209 -0
  16. data/docs/archive/REFACTORING_SUMMARY.md +184 -0
  17. data/docs/archive/phase_1_3_plan.md +136 -0
  18. data/docs/archive/sinatra_extension_summary.md +188 -0
  19. data/docs/archive/sinatra_working_solution.md +113 -0
  20. data/docs/archive/typescript-client-generator-summary.md +259 -0
  21. data/docs/auto-derivation.md +146 -0
  22. data/docs/blueprint.md +1091 -0
  23. data/docs/endpoint-definition.md +211 -0
  24. data/docs/github_pages_fix.md +52 -0
  25. data/docs/github_pages_setup.md +49 -0
  26. data/docs/implementation-status.md +357 -0
  27. data/docs/observability.md +647 -0
  28. data/docs/phase3-plan.md +108 -0
  29. data/docs/sinatra_rapitapir.md +87 -0
  30. data/docs/type_shortcuts.md +146 -0
  31. data/examples/README_ENTERPRISE.md +202 -0
  32. data/examples/authentication_example.rb +192 -0
  33. data/examples/auto_derivation_ruby_friendly.rb +163 -0
  34. data/examples/cli/user_api_endpoints.rb +56 -0
  35. data/examples/client/typescript_client_example.rb +102 -0
  36. data/examples/client/user-api-client.ts +193 -0
  37. data/examples/demo_api.rb +41 -0
  38. data/examples/docs/documentation_example.rb +112 -0
  39. data/examples/docs/user-api-docs.html +789 -0
  40. data/examples/docs/user-api-docs.md +403 -0
  41. data/examples/enhanced_auto_derivation_test.rb +83 -0
  42. data/examples/enterprise_extension_demo.rb +417 -0
  43. data/examples/enterprise_rapitapir_api.rb +662 -0
  44. data/examples/getting_started_extension.rb +218 -0
  45. data/examples/hello_world.rb +74 -0
  46. data/examples/oauth2/.env.example +19 -0
  47. data/examples/oauth2/README.md +205 -0
  48. data/examples/oauth2/generic_oauth2_api.rb +226 -0
  49. data/examples/oauth2/get_token.rb +72 -0
  50. data/examples/oauth2/songs_api_with_auth0.rb +320 -0
  51. data/examples/oauth2/test_api.sh +16 -0
  52. data/examples/oauth2/test_songs_api.sh +110 -0
  53. data/examples/observability/.env.example +35 -0
  54. data/examples/observability/README.md +230 -0
  55. data/examples/observability/README_HONEYCOMB.md +332 -0
  56. data/examples/observability/advanced_setup.rb +384 -0
  57. data/examples/observability/basic_setup.rb +192 -0
  58. data/examples/observability/complete_test.rb +121 -0
  59. data/examples/observability/honeycomb_example.rb +523 -0
  60. data/examples/observability/honeycomb_rapitapir_clean.rb +488 -0
  61. data/examples/observability/honeycomb_rapitapir_example.rb +523 -0
  62. data/examples/observability/honeycomb_working_example.rb +489 -0
  63. data/examples/observability/quick_test.rb +78 -0
  64. data/examples/observability/simple_test.rb +14 -0
  65. data/examples/observability/test_honeycomb_demo.rb +354 -0
  66. data/examples/observability/test_live_honeycomb.rb +111 -0
  67. data/examples/observability/test_validation.rb +78 -0
  68. data/examples/observability/test_working_validation.rb +66 -0
  69. data/examples/openapi/user_api_schema.rb +132 -0
  70. data/examples/production_ready_example.rb +105 -0
  71. data/examples/rails/users_controller.rb +146 -0
  72. data/examples/readme/basic_sinatra_example.rb +128 -0
  73. data/examples/server/user_api.rb +179 -0
  74. data/examples/simple_auto_derivation_demo.rb +44 -0
  75. data/examples/simple_demo_api.rb +18 -0
  76. data/examples/sinatra/user_app.rb +127 -0
  77. data/examples/t_shortcut_demo.rb +59 -0
  78. data/examples/user_api.rb +190 -0
  79. data/examples/working_getting_started.rb +184 -0
  80. data/examples/working_simple_example.rb +195 -0
  81. data/lib/rapitapir/auth/configuration.rb +129 -0
  82. data/lib/rapitapir/auth/context.rb +122 -0
  83. data/lib/rapitapir/auth/errors.rb +104 -0
  84. data/lib/rapitapir/auth/middleware.rb +324 -0
  85. data/lib/rapitapir/auth/oauth2.rb +350 -0
  86. data/lib/rapitapir/auth/schemes.rb +420 -0
  87. data/lib/rapitapir/auth.rb +113 -0
  88. data/lib/rapitapir/cli/command.rb +535 -0
  89. data/lib/rapitapir/cli/server.rb +243 -0
  90. data/lib/rapitapir/cli/validator.rb +373 -0
  91. data/lib/rapitapir/client/generator_base.rb +272 -0
  92. data/lib/rapitapir/client/typescript_generator.rb +350 -0
  93. data/lib/rapitapir/core/endpoint.rb +158 -0
  94. data/lib/rapitapir/core/enhanced_endpoint.rb +235 -0
  95. data/lib/rapitapir/core/input.rb +182 -0
  96. data/lib/rapitapir/core/output.rb +164 -0
  97. data/lib/rapitapir/core/request.rb +19 -0
  98. data/lib/rapitapir/core/response.rb +17 -0
  99. data/lib/rapitapir/docs/html_generator.rb +780 -0
  100. data/lib/rapitapir/docs/markdown_generator.rb +464 -0
  101. data/lib/rapitapir/dsl/endpoint_dsl.rb +116 -0
  102. data/lib/rapitapir/dsl/enhanced_endpoint_dsl.rb +62 -0
  103. data/lib/rapitapir/dsl/enhanced_input.rb +73 -0
  104. data/lib/rapitapir/dsl/enhanced_output.rb +63 -0
  105. data/lib/rapitapir/dsl/enhanced_structures.rb +393 -0
  106. data/lib/rapitapir/dsl/fluent_dsl.rb +72 -0
  107. data/lib/rapitapir/dsl/fluent_endpoint_builder.rb +316 -0
  108. data/lib/rapitapir/dsl/http_verbs.rb +77 -0
  109. data/lib/rapitapir/dsl/input_methods.rb +47 -0
  110. data/lib/rapitapir/dsl/observability_methods.rb +81 -0
  111. data/lib/rapitapir/dsl/output_methods.rb +43 -0
  112. data/lib/rapitapir/dsl/type_resolution.rb +43 -0
  113. data/lib/rapitapir/observability/configuration.rb +108 -0
  114. data/lib/rapitapir/observability/health_check.rb +236 -0
  115. data/lib/rapitapir/observability/logging.rb +270 -0
  116. data/lib/rapitapir/observability/metrics.rb +203 -0
  117. data/lib/rapitapir/observability/middleware.rb +243 -0
  118. data/lib/rapitapir/observability/tracing.rb +143 -0
  119. data/lib/rapitapir/observability.rb +28 -0
  120. data/lib/rapitapir/openapi/schema_generator.rb +403 -0
  121. data/lib/rapitapir/schema.rb +136 -0
  122. data/lib/rapitapir/server/enhanced_rack_adapter.rb +379 -0
  123. data/lib/rapitapir/server/middleware.rb +120 -0
  124. data/lib/rapitapir/server/path_matcher.rb +45 -0
  125. data/lib/rapitapir/server/rack_adapter.rb +215 -0
  126. data/lib/rapitapir/server/rails_adapter.rb +17 -0
  127. data/lib/rapitapir/server/rails_adapter_class.rb +53 -0
  128. data/lib/rapitapir/server/rails_controller.rb +72 -0
  129. data/lib/rapitapir/server/rails_input_processor.rb +73 -0
  130. data/lib/rapitapir/server/rails_response_handler.rb +29 -0
  131. data/lib/rapitapir/server/sinatra_adapter.rb +200 -0
  132. data/lib/rapitapir/server/sinatra_integration.rb +93 -0
  133. data/lib/rapitapir/sinatra/configuration.rb +91 -0
  134. data/lib/rapitapir/sinatra/extension.rb +214 -0
  135. data/lib/rapitapir/sinatra/oauth2_helpers.rb +236 -0
  136. data/lib/rapitapir/sinatra/resource_builder.rb +152 -0
  137. data/lib/rapitapir/sinatra/swagger_ui_generator.rb +166 -0
  138. data/lib/rapitapir/sinatra_rapitapir.rb +40 -0
  139. data/lib/rapitapir/types/array.rb +163 -0
  140. data/lib/rapitapir/types/auto_derivation.rb +265 -0
  141. data/lib/rapitapir/types/base.rb +146 -0
  142. data/lib/rapitapir/types/boolean.rb +46 -0
  143. data/lib/rapitapir/types/date.rb +92 -0
  144. data/lib/rapitapir/types/datetime.rb +98 -0
  145. data/lib/rapitapir/types/email.rb +32 -0
  146. data/lib/rapitapir/types/float.rb +134 -0
  147. data/lib/rapitapir/types/hash.rb +161 -0
  148. data/lib/rapitapir/types/integer.rb +143 -0
  149. data/lib/rapitapir/types/object.rb +156 -0
  150. data/lib/rapitapir/types/optional.rb +65 -0
  151. data/lib/rapitapir/types/string.rb +185 -0
  152. data/lib/rapitapir/types/uuid.rb +32 -0
  153. data/lib/rapitapir/types.rb +155 -0
  154. data/lib/rapitapir/version.rb +5 -0
  155. data/lib/rapitapir.rb +173 -0
  156. data/rapitapir.gemspec +66 -0
  157. metadata +387 -0
@@ -0,0 +1,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'uri'
5
+ require 'ostruct'
6
+ require_relative 'context'
7
+ require_relative 'errors'
8
+
9
+ module RapiTapir
10
+ module Auth
11
+ module Middleware
12
+ # Middleware for handling authentication
13
+ # Processes incoming requests and attempts to authenticate them using configured schemes
14
+ class AuthenticationMiddleware
15
+ def initialize(app, auth_schemes = {})
16
+ @app = app
17
+ @auth_schemes = auth_schemes
18
+ end
19
+
20
+ def call(env)
21
+ request = create_request(env)
22
+ auth_context = authenticate_request(request)
23
+
24
+ # Store context for the request
25
+ ContextStore.with_context(auth_context) do
26
+ @app.call(env)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def create_request(env)
33
+ # Create a simple request object
34
+ Struct.new(:env, :params, :headers).new(
35
+ env,
36
+ parse_query_params(env['QUERY_STRING']),
37
+ extract_headers(env)
38
+ )
39
+ end
40
+
41
+ def parse_query_params(query_string)
42
+ return {} if query_string.nil? || query_string.empty?
43
+
44
+ URI.decode_www_form(query_string).to_h
45
+ end
46
+
47
+ def extract_headers(env)
48
+ headers = {}
49
+ env.each do |key, value|
50
+ if key.start_with?('HTTP_')
51
+ header_name = key[5..].downcase.tr('_', '-')
52
+ headers[header_name] = value
53
+ end
54
+ end
55
+ headers
56
+ end
57
+
58
+ def authenticate_request(request)
59
+ @auth_schemes.each_value do |scheme|
60
+ context = scheme.authenticate(request)
61
+ return context if context&.authenticated?
62
+ end
63
+
64
+ # Return empty context if no authentication succeeded
65
+ Context.new
66
+ end
67
+ end
68
+
69
+ # Middleware for handling authorization
70
+ # Checks if authenticated users have required permissions/scopes
71
+ class AuthorizationMiddleware
72
+ def initialize(app, required_scopes: [], require_all: true)
73
+ @app = app
74
+ @required_scopes = Array(required_scopes)
75
+ @require_all = require_all
76
+ end
77
+
78
+ def call(env)
79
+ context = ContextStore.current
80
+
81
+ return unauthorized_response('Authentication required') unless context&.authenticated?
82
+
83
+ return forbidden_response('Insufficient permissions') unless authorized?(context)
84
+
85
+ @app.call(env)
86
+ end
87
+
88
+ private
89
+
90
+ def authorized?(context)
91
+ return true if @required_scopes.empty?
92
+
93
+ if @require_all
94
+ context.has_all_scopes?(*@required_scopes)
95
+ else
96
+ context.has_any_scope?(*@required_scopes)
97
+ end
98
+ end
99
+
100
+ def unauthorized_response(message)
101
+ [
102
+ 401,
103
+ { 'Content-Type' => 'application/json' },
104
+ [JSON.generate({ error: 'Unauthorized', message: message })]
105
+ ]
106
+ end
107
+
108
+ def forbidden_response(message)
109
+ [
110
+ 403,
111
+ { 'Content-Type' => 'application/json' },
112
+ [JSON.generate({ error: 'Forbidden', message: message })]
113
+ ]
114
+ end
115
+ end
116
+
117
+ # Middleware for rate limiting requests
118
+ # Limits the number of requests per client based on configurable rules
119
+ class RateLimitingMiddleware
120
+ def initialize(app, config = {})
121
+ @app = app
122
+ @requests_per_minute = config[:requests_per_minute] || 60
123
+ @requests_per_hour = config[:requests_per_hour] || 1000
124
+ @storage = config[:storage] || MemoryStorage.new
125
+ @key_generator = config[:key_generator] || method(:default_key_generator)
126
+ end
127
+
128
+ def call(env)
129
+ key = @key_generator.call(env)
130
+
131
+ return rate_limit_exceeded_response unless rate_limit_allowed?(key)
132
+
133
+ record_request(key)
134
+ @app.call(env)
135
+ end
136
+
137
+ private
138
+
139
+ def rate_limit_allowed?(key)
140
+ minute_key = "#{key}:minute:#{Time.now.to_i / 60}"
141
+ hour_key = "#{key}:hour:#{Time.now.to_i / 3600}"
142
+
143
+ minute_count = @storage.get(minute_key) || 0
144
+ hour_count = @storage.get(hour_key) || 0
145
+
146
+ minute_count < @requests_per_minute && hour_count < @requests_per_hour
147
+ end
148
+
149
+ def record_request(key)
150
+ minute_key = "#{key}:minute:#{Time.now.to_i / 60}"
151
+ hour_key = "#{key}:hour:#{Time.now.to_i / 3600}"
152
+
153
+ @storage.increment(minute_key, expires_in: 60)
154
+ @storage.increment(hour_key, expires_in: 3600)
155
+ end
156
+
157
+ def default_key_generator(env)
158
+ # Use IP address and user ID if available
159
+ ip = env['REMOTE_ADDR'] || env['HTTP_X_FORWARDED_FOR']
160
+ context = ContextStore.current
161
+ user_id = context&.user_id
162
+
163
+ user_id ? "user:#{user_id}" : "ip:#{ip}"
164
+ end
165
+
166
+ def rate_limit_exceeded_response
167
+ [
168
+ 429,
169
+ {
170
+ 'Content-Type' => 'application/json',
171
+ 'Retry-After' => '60'
172
+ },
173
+ [JSON.generate({
174
+ error: 'Rate Limit Exceeded',
175
+ message: 'Too many requests. Please try again later.'
176
+ })]
177
+ ]
178
+ end
179
+
180
+ # Simple in-memory storage for rate limiting
181
+ class MemoryStorage
182
+ def initialize
183
+ @storage = {}
184
+ @mutex = Mutex.new
185
+ end
186
+
187
+ def get(key)
188
+ @mutex.synchronize do
189
+ entry = @storage[key]
190
+ return nil unless entry
191
+ return nil if entry[:expires_at] && Time.now > entry[:expires_at]
192
+
193
+ entry[:value]
194
+ end
195
+ end
196
+
197
+ def increment(key, expires_in: nil)
198
+ @mutex.synchronize do
199
+ entry = @storage[key]
200
+ current_value = 0
201
+
202
+ current_value = entry[:value] if entry && (!entry[:expires_at] || Time.now <= entry[:expires_at])
203
+
204
+ expires_at = expires_in ? Time.now + expires_in : nil
205
+
206
+ @storage[key] = {
207
+ value: current_value + 1,
208
+ expires_at: expires_at
209
+ }
210
+ end
211
+ end
212
+
213
+ def cleanup_expired
214
+ @mutex.synchronize do
215
+ now = Time.now
216
+ @storage.reject! do |_key, entry|
217
+ entry[:expires_at] && now > entry[:expires_at]
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end
223
+
224
+ # Middleware for handling Cross-Origin Resource Sharing (CORS)
225
+ # Adds CORS headers to enable cross-origin requests from browsers
226
+ class CorsMiddleware
227
+ def initialize(app, config = {})
228
+ @app = app
229
+ @allowed_origins = config[:allowed_origins] || ['*']
230
+ @allowed_methods = config[:allowed_methods] || %w[GET POST PUT DELETE OPTIONS]
231
+ @allowed_headers = config[:allowed_headers] || %w[Authorization Content-Type X-API-Key]
232
+ @max_age = config[:max_age] || 86_400
233
+ @allow_credentials = config[:allow_credentials] || false
234
+ end
235
+
236
+ def call(env)
237
+ origin = env['HTTP_ORIGIN']
238
+
239
+ # Handle preflight requests
240
+ return preflight_response(origin) if env['REQUEST_METHOD'] == 'OPTIONS'
241
+
242
+ status, headers, body = @app.call(env)
243
+
244
+ # Add CORS headers to actual requests
245
+ headers = add_cors_headers(headers, origin)
246
+
247
+ [status, headers, body]
248
+ end
249
+
250
+ private
251
+
252
+ def preflight_response(origin)
253
+ headers = {
254
+ 'Content-Type' => 'text/plain',
255
+ 'Content-Length' => '0'
256
+ }
257
+
258
+ headers = add_cors_headers(headers, origin)
259
+ headers['Access-Control-Max-Age'] = @max_age.to_s
260
+
261
+ [200, headers, ['']]
262
+ end
263
+
264
+ def add_cors_headers(headers, origin)
265
+ headers = headers.dup
266
+
267
+ if @allowed_origins.include?('*')
268
+ headers['Access-Control-Allow-Origin'] = '*'
269
+ elsif origin_allowed?(origin)
270
+ headers['Access-Control-Allow-Origin'] = origin
271
+ end
272
+
273
+ headers['Access-Control-Allow-Methods'] = @allowed_methods.join(', ')
274
+ headers['Access-Control-Allow-Headers'] = @allowed_headers.join(', ')
275
+
276
+ headers['Access-Control-Allow-Credentials'] = 'true' if @allow_credentials
277
+
278
+ headers
279
+ end
280
+
281
+ def origin_allowed?(origin)
282
+ return false unless origin
283
+ return true if @allowed_origins.include?('*')
284
+
285
+ @allowed_origins.any? do |allowed|
286
+ if allowed.include?('*')
287
+ # Simple wildcard matching
288
+ pattern = allowed.gsub('*', '.*')
289
+ origin.match?(/\A#{pattern}\z/)
290
+ else
291
+ origin == allowed
292
+ end
293
+ end
294
+ end
295
+ end
296
+
297
+ # Middleware for adding security headers
298
+ # Adds common security headers to HTTP responses for protection
299
+ class SecurityHeadersMiddleware
300
+ def initialize(app, config = {})
301
+ @app = app
302
+ @headers = {
303
+ 'X-Content-Type-Options' => 'nosniff',
304
+ 'X-Frame-Options' => 'DENY',
305
+ 'X-XSS-Protection' => '1; mode=block',
306
+ 'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains',
307
+ 'Referrer-Policy' => 'strict-origin-when-cross-origin'
308
+ }.merge(config[:headers] || {})
309
+ end
310
+
311
+ def call(env)
312
+ status, headers, body = @app.call(env)
313
+
314
+ # Add security headers
315
+ @headers.each do |name, value|
316
+ headers[name] = value unless headers.key?(name)
317
+ end
318
+
319
+ [status, headers, body]
320
+ end
321
+ end
322
+ end
323
+ end
324
+ end
@@ -0,0 +1,350 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+
7
+ module RapiTapir
8
+ module Auth
9
+ module OAuth2
10
+ # Enhanced OAuth2 authentication scheme with Auth0 support
11
+ # Based on the Auth0 Sinatra integration patterns
12
+ class Auth0Scheme < Schemes::Base
13
+ attr_reader :domain, :audience, :algorithm, :jwks_cache_ttl
14
+
15
+ def jwks_url
16
+ "https://#{@domain}/.well-known/jwks.json"
17
+ end
18
+
19
+ def initialize(name = :oauth2_auth0, config = {})
20
+ super(name, config)
21
+
22
+ @domain = config[:domain] || raise(ArgumentError, 'Auth0 domain is required')
23
+ @audience = config[:audience] || raise(ArgumentError, 'Auth0 audience is required')
24
+ @algorithm = config[:algorithm] || 'RS256'
25
+ @jwks_cache_ttl = config[:jwks_cache_ttl] || 300 # 5 minutes
26
+ @realm = config[:realm] || 'API'
27
+
28
+ # Cache for JWKS to avoid frequent requests
29
+ @jwks_cache = nil
30
+ @jwks_cache_time = nil
31
+ end
32
+
33
+ def authenticate(request)
34
+ auth_header = request.env['HTTP_AUTHORIZATION']
35
+ return nil unless auth_header
36
+
37
+ token = extract_bearer_token(auth_header)
38
+ if token.nil?
39
+ raise AuthenticationError, "Invalid authorization header format"
40
+ end
41
+
42
+ if token.strip.empty?
43
+ raise AuthenticationError, "Token cannot be empty"
44
+ end
45
+
46
+ begin
47
+ decoded_token = validate_auth0_token(token)
48
+ return nil unless decoded_token
49
+
50
+ # Extract user info and scopes from the token
51
+ payload = decoded_token.first
52
+
53
+ create_context(
54
+ user: extract_user_from_token(payload),
55
+ scopes: extract_scopes_from_token(payload),
56
+ token: token,
57
+ metadata: {
58
+ token_type: 'oauth2_auth0',
59
+ issuer: payload['iss'],
60
+ subject: payload['sub'],
61
+ audience: payload['aud'],
62
+ expires_at: payload['exp'] ? Time.at(payload['exp']) : nil,
63
+ issued_at: payload['iat'] ? Time.at(payload['iat']) : nil
64
+ }
65
+ )
66
+ rescue JWT::VerificationError, JWT::DecodeError => e
67
+ raise InvalidTokenError, "Invalid Auth0 token: #{e.message}"
68
+ rescue StandardError => e
69
+ raise AuthenticationError, "Auth0 authentication failed: #{e.message}"
70
+ end
71
+ end
72
+
73
+ def challenge
74
+ "Bearer realm=\"#{@realm}\", error=\"invalid_token\", error_description=\"The access token provided is expired, revoked, malformed, or invalid\""
75
+ end
76
+
77
+ # Public method to verify token (useful for testing)
78
+ def verify_token(token)
79
+ validate_auth0_token(token)
80
+ end
81
+
82
+ private
83
+
84
+ def extract_bearer_token(auth_header)
85
+ match = auth_header.match(/\ABearer\s+(.+)\z/i)
86
+ match ? match[1] : nil
87
+ end
88
+
89
+ def validate_auth0_token(token)
90
+ require 'jwt' # Ensure JWT gem is available
91
+
92
+ jwks = fetch_jwks
93
+
94
+ JWT.decode(
95
+ token,
96
+ nil,
97
+ true,
98
+ {
99
+ algorithm: @algorithm,
100
+ iss: domain_url,
101
+ verify_iss: true,
102
+ aud: @audience,
103
+ verify_aud: true,
104
+ jwks: jwks
105
+ }
106
+ )
107
+ end
108
+
109
+ def fetch_jwks
110
+ # Return cached JWKS if still valid
111
+ if @jwks_cache && @jwks_cache_time && (Time.now - @jwks_cache_time) < @jwks_cache_ttl
112
+ return @jwks_cache
113
+ end
114
+
115
+ # Fetch fresh JWKS from Auth0
116
+ jwks_response = get_jwks_from_auth0
117
+
118
+ unless jwks_response.is_a?(Net::HTTPSuccess)
119
+ raise AuthenticationError, 'Unable to fetch JWKS from Auth0'
120
+ end
121
+
122
+ jwks_data = JSON.parse(jwks_response.body)
123
+
124
+ # Cache the JWKS
125
+ @jwks_cache = { keys: jwks_data['keys'] }
126
+ @jwks_cache_time = Time.now
127
+
128
+ @jwks_cache
129
+ end
130
+
131
+ def get_jwks_from_auth0
132
+ jwks_uri = URI("#{base_domain_url}/.well-known/jwks.json")
133
+
134
+ http = Net::HTTP.new(jwks_uri.host, jwks_uri.port)
135
+ http.use_ssl = true
136
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
137
+ http.read_timeout = 10
138
+
139
+ request = Net::HTTP::Get.new(jwks_uri.request_uri)
140
+ request['User-Agent'] = 'RapiTapir OAuth2 Client'
141
+
142
+ http.request(request)
143
+ end
144
+
145
+ def domain_url
146
+ url = @domain.start_with?('https://') ? @domain : "https://#{@domain}"
147
+ # Auth0 issuer always includes trailing slash for JWT validation
148
+ @domain_url ||= url.end_with?('/') ? url : "#{url}/"
149
+ end
150
+
151
+ def base_domain_url
152
+ @base_domain_url ||= @domain.start_with?('https://') ? @domain : "https://#{@domain}"
153
+ end
154
+
155
+ def extract_user_from_token(payload)
156
+ {
157
+ id: payload['sub'],
158
+ email: payload['email'],
159
+ name: payload['name'] || payload['nickname'],
160
+ picture: payload['picture'],
161
+ email_verified: payload['email_verified']
162
+ }.compact
163
+ end
164
+
165
+ def extract_scopes_from_token(payload)
166
+ scope_string = payload['scope']
167
+ return [] unless scope_string
168
+
169
+ scope_string.split(' ')
170
+ end
171
+ end
172
+
173
+ # Generic OAuth2 scheme with token introspection support
174
+ class GenericScheme < Schemes::Base
175
+ attr_reader :introspection_endpoint, :client_id, :client_secret, :token_cache_ttl
176
+
177
+ def initialize(name = :oauth2, config = {})
178
+ super(name, config)
179
+
180
+ @introspection_endpoint = config[:introspection_endpoint]
181
+ @client_id = config[:client_id]
182
+ @client_secret = config[:client_secret]
183
+ @token_validator = config[:token_validator]
184
+ @realm = config[:realm] || 'API'
185
+ @cache_tokens = config.fetch(:cache_tokens, true)
186
+ @token_cache_ttl = config[:token_cache_ttl] || 300 # 5 minutes
187
+
188
+ # Token cache to avoid repeated introspection calls
189
+ @token_cache = {} if @cache_tokens
190
+ end
191
+
192
+ def authenticate(request)
193
+ auth_header = request.env['HTTP_AUTHORIZATION']
194
+ return nil unless auth_header
195
+
196
+ token = extract_bearer_token(auth_header)
197
+ return nil unless token
198
+
199
+ begin
200
+ token_info = validate_oauth2_token(token)
201
+
202
+ # Check if token is active
203
+ unless token_info && token_info[:active]
204
+ raise AuthenticationError, "Token is not active"
205
+ end
206
+
207
+ create_context(
208
+ user: token_info[:user],
209
+ scopes: token_info[:scopes] || [],
210
+ token: token,
211
+ metadata: {
212
+ token_type: 'oauth2',
213
+ client_id: token_info[:client_id],
214
+ expires_at: token_info[:expires_at],
215
+ token_type_hint: token_info[:token_type]
216
+ }
217
+ )
218
+ rescue AuthenticationError
219
+ raise # Re-raise authentication errors
220
+ rescue StandardError => e
221
+ raise AuthenticationError, "OAuth2 authentication failed: #{e.message}"
222
+ end
223
+ end
224
+
225
+ def challenge
226
+ "Bearer realm=\"#{@realm}\""
227
+ end
228
+
229
+ # Public method for token validation (useful for testing)
230
+ def validate_token(token)
231
+ validate_oauth2_token(token)
232
+ end
233
+
234
+ private
235
+
236
+ def extract_bearer_token(auth_header)
237
+ match = auth_header.match(/\ABearer\s+(.+)\z/i)
238
+ match ? match[1] : nil
239
+ end
240
+
241
+ def validate_oauth2_token(token)
242
+ # Check cache first
243
+ if @cache_tokens && @token_cache
244
+ cached = @token_cache[token]
245
+ if cached && (Time.now - cached[:cached_at]) < @token_cache_ttl
246
+ return cached[:data]
247
+ end
248
+ end
249
+
250
+ # Validate token
251
+ token_info = if @introspection_endpoint
252
+ introspect_token_via_endpoint(token)
253
+ elsif @token_validator
254
+ @token_validator.call(token)
255
+ else
256
+ default_token_validation(token)
257
+ end
258
+
259
+ # Cache the result
260
+ if @cache_tokens && @token_cache && token_info
261
+ @token_cache[token] = {
262
+ data: token_info,
263
+ cached_at: Time.now
264
+ }
265
+ end
266
+
267
+ token_info
268
+ end
269
+
270
+ def introspect_token_via_endpoint(token)
271
+ uri = URI(@introspection_endpoint)
272
+
273
+ http = Net::HTTP.new(uri.host, uri.port)
274
+ http.use_ssl = (uri.scheme == 'https')
275
+
276
+ request = Net::HTTP::Post.new(uri.request_uri)
277
+ request['Content-Type'] = 'application/x-www-form-urlencoded'
278
+ request['Accept'] = 'application/json'
279
+
280
+ # Add client authentication
281
+ if @client_id && @client_secret
282
+ credentials = Base64.strict_encode64("#{@client_id}:#{@client_secret}")
283
+ request['Authorization'] = "Basic #{credentials}"
284
+ end
285
+
286
+ # Set form data
287
+ request.set_form_data(
288
+ 'token' => token,
289
+ 'token_type_hint' => 'access_token'
290
+ )
291
+
292
+ response = http.request(request)
293
+
294
+ unless response.is_a?(Net::HTTPSuccess)
295
+ raise AuthenticationError, "Token introspection failed: #{response.code} #{response.message}"
296
+ end
297
+
298
+ begin
299
+ introspection_data = JSON.parse(response.body)
300
+ rescue JSON::ParserError => e
301
+ raise AuthenticationError, "Invalid introspection response: #{e.message}"
302
+ end
303
+
304
+ return { active: false } unless introspection_data['active']
305
+
306
+ {
307
+ active: true,
308
+ user: extract_user_from_introspection(introspection_data),
309
+ scopes: extract_scopes_from_introspection(introspection_data),
310
+ client_id: introspection_data['client_id'],
311
+ expires_at: introspection_data['exp'] ? Time.at(introspection_data['exp']) : nil,
312
+ token_type: introspection_data['token_type'] || 'Bearer'
313
+ }
314
+ end
315
+
316
+ def extract_user_from_introspection(data)
317
+ {
318
+ id: data['sub'] || data['user_id'],
319
+ username: data['username'],
320
+ email: data['email'],
321
+ name: data['name']
322
+ }.compact
323
+ end
324
+
325
+ def extract_scopes_from_introspection(data)
326
+ if data['scope'].is_a?(String)
327
+ data['scope'].split(' ')
328
+ elsif data['scope'].is_a?(Array)
329
+ data['scope']
330
+ else
331
+ []
332
+ end
333
+ end
334
+
335
+ def default_token_validation(token)
336
+ # Simple default validation - should be overridden in production
337
+ return { active: false } if token.nil? || token.empty?
338
+
339
+ {
340
+ active: true,
341
+ user: { id: 'default_user', name: 'Default User' },
342
+ scopes: %w[read],
343
+ client_id: 'default_client',
344
+ expires_at: Time.now + 3600
345
+ }
346
+ end
347
+ end
348
+ end
349
+ end
350
+ end