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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +57 -0
- data/CHANGELOG.md +94 -0
- data/CLEANUP_SUMMARY.md +155 -0
- data/CONTRIBUTING.md +280 -0
- data/LICENSE +21 -0
- data/README.md +485 -0
- data/debug_hash.rb +20 -0
- data/docs/EXTENSION_COMPARISON.md +388 -0
- data/docs/SINATRA_EXTENSION.md +467 -0
- data/docs/archive/PHASE_1_2_COMPLETE.md +77 -0
- data/docs/archive/PHASE_1_3_COMPLETE.md +152 -0
- data/docs/archive/PHASE_2_1_OBSERVABILITY_COMPLETED.md +203 -0
- data/docs/archive/PHASE_2_SUMMARY.md +209 -0
- data/docs/archive/REFACTORING_SUMMARY.md +184 -0
- data/docs/archive/phase_1_3_plan.md +136 -0
- data/docs/archive/sinatra_extension_summary.md +188 -0
- data/docs/archive/sinatra_working_solution.md +113 -0
- data/docs/archive/typescript-client-generator-summary.md +259 -0
- data/docs/auto-derivation.md +146 -0
- data/docs/blueprint.md +1091 -0
- data/docs/endpoint-definition.md +211 -0
- data/docs/github_pages_fix.md +52 -0
- data/docs/github_pages_setup.md +49 -0
- data/docs/implementation-status.md +357 -0
- data/docs/observability.md +647 -0
- data/docs/phase3-plan.md +108 -0
- data/docs/sinatra_rapitapir.md +87 -0
- data/docs/type_shortcuts.md +146 -0
- data/examples/README_ENTERPRISE.md +202 -0
- data/examples/authentication_example.rb +192 -0
- data/examples/auto_derivation_ruby_friendly.rb +163 -0
- data/examples/cli/user_api_endpoints.rb +56 -0
- data/examples/client/typescript_client_example.rb +102 -0
- data/examples/client/user-api-client.ts +193 -0
- data/examples/demo_api.rb +41 -0
- data/examples/docs/documentation_example.rb +112 -0
- data/examples/docs/user-api-docs.html +789 -0
- data/examples/docs/user-api-docs.md +403 -0
- data/examples/enhanced_auto_derivation_test.rb +83 -0
- data/examples/enterprise_extension_demo.rb +417 -0
- data/examples/enterprise_rapitapir_api.rb +662 -0
- data/examples/getting_started_extension.rb +218 -0
- data/examples/hello_world.rb +74 -0
- data/examples/oauth2/.env.example +19 -0
- data/examples/oauth2/README.md +205 -0
- data/examples/oauth2/generic_oauth2_api.rb +226 -0
- data/examples/oauth2/get_token.rb +72 -0
- data/examples/oauth2/songs_api_with_auth0.rb +320 -0
- data/examples/oauth2/test_api.sh +16 -0
- data/examples/oauth2/test_songs_api.sh +110 -0
- data/examples/observability/.env.example +35 -0
- data/examples/observability/README.md +230 -0
- data/examples/observability/README_HONEYCOMB.md +332 -0
- data/examples/observability/advanced_setup.rb +384 -0
- data/examples/observability/basic_setup.rb +192 -0
- data/examples/observability/complete_test.rb +121 -0
- data/examples/observability/honeycomb_example.rb +523 -0
- data/examples/observability/honeycomb_rapitapir_clean.rb +488 -0
- data/examples/observability/honeycomb_rapitapir_example.rb +523 -0
- data/examples/observability/honeycomb_working_example.rb +489 -0
- data/examples/observability/quick_test.rb +78 -0
- data/examples/observability/simple_test.rb +14 -0
- data/examples/observability/test_honeycomb_demo.rb +354 -0
- data/examples/observability/test_live_honeycomb.rb +111 -0
- data/examples/observability/test_validation.rb +78 -0
- data/examples/observability/test_working_validation.rb +66 -0
- data/examples/openapi/user_api_schema.rb +132 -0
- data/examples/production_ready_example.rb +105 -0
- data/examples/rails/users_controller.rb +146 -0
- data/examples/readme/basic_sinatra_example.rb +128 -0
- data/examples/server/user_api.rb +179 -0
- data/examples/simple_auto_derivation_demo.rb +44 -0
- data/examples/simple_demo_api.rb +18 -0
- data/examples/sinatra/user_app.rb +127 -0
- data/examples/t_shortcut_demo.rb +59 -0
- data/examples/user_api.rb +190 -0
- data/examples/working_getting_started.rb +184 -0
- data/examples/working_simple_example.rb +195 -0
- data/lib/rapitapir/auth/configuration.rb +129 -0
- data/lib/rapitapir/auth/context.rb +122 -0
- data/lib/rapitapir/auth/errors.rb +104 -0
- data/lib/rapitapir/auth/middleware.rb +324 -0
- data/lib/rapitapir/auth/oauth2.rb +350 -0
- data/lib/rapitapir/auth/schemes.rb +420 -0
- data/lib/rapitapir/auth.rb +113 -0
- data/lib/rapitapir/cli/command.rb +535 -0
- data/lib/rapitapir/cli/server.rb +243 -0
- data/lib/rapitapir/cli/validator.rb +373 -0
- data/lib/rapitapir/client/generator_base.rb +272 -0
- data/lib/rapitapir/client/typescript_generator.rb +350 -0
- data/lib/rapitapir/core/endpoint.rb +158 -0
- data/lib/rapitapir/core/enhanced_endpoint.rb +235 -0
- data/lib/rapitapir/core/input.rb +182 -0
- data/lib/rapitapir/core/output.rb +164 -0
- data/lib/rapitapir/core/request.rb +19 -0
- data/lib/rapitapir/core/response.rb +17 -0
- data/lib/rapitapir/docs/html_generator.rb +780 -0
- data/lib/rapitapir/docs/markdown_generator.rb +464 -0
- data/lib/rapitapir/dsl/endpoint_dsl.rb +116 -0
- data/lib/rapitapir/dsl/enhanced_endpoint_dsl.rb +62 -0
- data/lib/rapitapir/dsl/enhanced_input.rb +73 -0
- data/lib/rapitapir/dsl/enhanced_output.rb +63 -0
- data/lib/rapitapir/dsl/enhanced_structures.rb +393 -0
- data/lib/rapitapir/dsl/fluent_dsl.rb +72 -0
- data/lib/rapitapir/dsl/fluent_endpoint_builder.rb +316 -0
- data/lib/rapitapir/dsl/http_verbs.rb +77 -0
- data/lib/rapitapir/dsl/input_methods.rb +47 -0
- data/lib/rapitapir/dsl/observability_methods.rb +81 -0
- data/lib/rapitapir/dsl/output_methods.rb +43 -0
- data/lib/rapitapir/dsl/type_resolution.rb +43 -0
- data/lib/rapitapir/observability/configuration.rb +108 -0
- data/lib/rapitapir/observability/health_check.rb +236 -0
- data/lib/rapitapir/observability/logging.rb +270 -0
- data/lib/rapitapir/observability/metrics.rb +203 -0
- data/lib/rapitapir/observability/middleware.rb +243 -0
- data/lib/rapitapir/observability/tracing.rb +143 -0
- data/lib/rapitapir/observability.rb +28 -0
- data/lib/rapitapir/openapi/schema_generator.rb +403 -0
- data/lib/rapitapir/schema.rb +136 -0
- data/lib/rapitapir/server/enhanced_rack_adapter.rb +379 -0
- data/lib/rapitapir/server/middleware.rb +120 -0
- data/lib/rapitapir/server/path_matcher.rb +45 -0
- data/lib/rapitapir/server/rack_adapter.rb +215 -0
- data/lib/rapitapir/server/rails_adapter.rb +17 -0
- data/lib/rapitapir/server/rails_adapter_class.rb +53 -0
- data/lib/rapitapir/server/rails_controller.rb +72 -0
- data/lib/rapitapir/server/rails_input_processor.rb +73 -0
- data/lib/rapitapir/server/rails_response_handler.rb +29 -0
- data/lib/rapitapir/server/sinatra_adapter.rb +200 -0
- data/lib/rapitapir/server/sinatra_integration.rb +93 -0
- data/lib/rapitapir/sinatra/configuration.rb +91 -0
- data/lib/rapitapir/sinatra/extension.rb +214 -0
- data/lib/rapitapir/sinatra/oauth2_helpers.rb +236 -0
- data/lib/rapitapir/sinatra/resource_builder.rb +152 -0
- data/lib/rapitapir/sinatra/swagger_ui_generator.rb +166 -0
- data/lib/rapitapir/sinatra_rapitapir.rb +40 -0
- data/lib/rapitapir/types/array.rb +163 -0
- data/lib/rapitapir/types/auto_derivation.rb +265 -0
- data/lib/rapitapir/types/base.rb +146 -0
- data/lib/rapitapir/types/boolean.rb +46 -0
- data/lib/rapitapir/types/date.rb +92 -0
- data/lib/rapitapir/types/datetime.rb +98 -0
- data/lib/rapitapir/types/email.rb +32 -0
- data/lib/rapitapir/types/float.rb +134 -0
- data/lib/rapitapir/types/hash.rb +161 -0
- data/lib/rapitapir/types/integer.rb +143 -0
- data/lib/rapitapir/types/object.rb +156 -0
- data/lib/rapitapir/types/optional.rb +65 -0
- data/lib/rapitapir/types/string.rb +185 -0
- data/lib/rapitapir/types/uuid.rb +32 -0
- data/lib/rapitapir/types.rb +155 -0
- data/lib/rapitapir/version.rb +5 -0
- data/lib/rapitapir.rb +173 -0
- data/rapitapir.gemspec +66 -0
- 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
|