tsikol 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/CHANGELOG.md +22 -0
- data/CONTRIBUTING.md +84 -0
- data/LICENSE +21 -0
- data/README.md +579 -0
- data/Rakefile +12 -0
- data/docs/README.md +69 -0
- data/docs/api/middleware.md +721 -0
- data/docs/api/prompt.md +858 -0
- data/docs/api/resource.md +651 -0
- data/docs/api/server.md +509 -0
- data/docs/api/test-helpers.md +591 -0
- data/docs/api/tool.md +527 -0
- data/docs/cookbook/authentication.md +651 -0
- data/docs/cookbook/caching.md +877 -0
- data/docs/cookbook/dynamic-tools.md +970 -0
- data/docs/cookbook/error-handling.md +887 -0
- data/docs/cookbook/logging.md +1044 -0
- data/docs/cookbook/rate-limiting.md +717 -0
- data/docs/examples/code-assistant.md +922 -0
- data/docs/examples/complete-server.md +726 -0
- data/docs/examples/database-manager.md +1198 -0
- data/docs/examples/devops-tools.md +1382 -0
- data/docs/examples/echo-server.md +501 -0
- data/docs/examples/weather-service.md +822 -0
- data/docs/guides/completion.md +472 -0
- data/docs/guides/getting-started.md +462 -0
- data/docs/guides/middleware.md +823 -0
- data/docs/guides/project-structure.md +434 -0
- data/docs/guides/prompts.md +920 -0
- data/docs/guides/resources.md +720 -0
- data/docs/guides/sampling.md +804 -0
- data/docs/guides/testing.md +863 -0
- data/docs/guides/tools.md +627 -0
- data/examples/README.md +92 -0
- data/examples/advanced_features.rb +129 -0
- data/examples/basic-migrated/app/prompts/weather_chat.rb +44 -0
- data/examples/basic-migrated/app/resources/weather_alerts.rb +18 -0
- data/examples/basic-migrated/app/tools/get_current_weather.rb +34 -0
- data/examples/basic-migrated/app/tools/get_forecast.rb +30 -0
- data/examples/basic-migrated/app/tools/get_weather_by_coords.rb +48 -0
- data/examples/basic-migrated/server.rb +25 -0
- data/examples/basic.rb +73 -0
- data/examples/full_featured.rb +175 -0
- data/examples/middleware_example.rb +112 -0
- data/examples/sampling_example.rb +104 -0
- data/examples/weather-service/app/prompts/weather/chat.rb +90 -0
- data/examples/weather-service/app/resources/weather/alerts.rb +59 -0
- data/examples/weather-service/app/tools/weather/get_current.rb +82 -0
- data/examples/weather-service/app/tools/weather/get_forecast.rb +90 -0
- data/examples/weather-service/server.rb +28 -0
- data/exe/tsikol +6 -0
- data/lib/tsikol/cli/templates/Gemfile.erb +10 -0
- data/lib/tsikol/cli/templates/README.md.erb +38 -0
- data/lib/tsikol/cli/templates/gitignore.erb +49 -0
- data/lib/tsikol/cli/templates/prompt.rb.erb +53 -0
- data/lib/tsikol/cli/templates/resource.rb.erb +29 -0
- data/lib/tsikol/cli/templates/server.rb.erb +24 -0
- data/lib/tsikol/cli/templates/tool.rb.erb +60 -0
- data/lib/tsikol/cli.rb +203 -0
- data/lib/tsikol/error_handler.rb +141 -0
- data/lib/tsikol/health.rb +198 -0
- data/lib/tsikol/http_transport.rb +72 -0
- data/lib/tsikol/lifecycle.rb +149 -0
- data/lib/tsikol/middleware.rb +168 -0
- data/lib/tsikol/prompt.rb +101 -0
- data/lib/tsikol/resource.rb +53 -0
- data/lib/tsikol/router.rb +190 -0
- data/lib/tsikol/server.rb +660 -0
- data/lib/tsikol/stdio_transport.rb +108 -0
- data/lib/tsikol/test_helpers.rb +261 -0
- data/lib/tsikol/tool.rb +111 -0
- data/lib/tsikol/version.rb +5 -0
- data/lib/tsikol.rb +72 -0
- metadata +219 -0
@@ -0,0 +1,651 @@
|
|
1
|
+
# Authentication Recipe
|
2
|
+
|
3
|
+
This recipe shows how to add authentication to your MCP server to secure tools and resources.
|
4
|
+
|
5
|
+
## Basic Token Authentication
|
6
|
+
|
7
|
+
### Simple Token Middleware
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
class TokenAuthMiddleware < Tsikol::Middleware
|
11
|
+
def initialize(app, options = {})
|
12
|
+
@app = app
|
13
|
+
@token = options[:token] || ENV['MCP_AUTH_TOKEN']
|
14
|
+
@protected_methods = options[:protected_methods] || ["tools/call", "resources/read"]
|
15
|
+
end
|
16
|
+
|
17
|
+
def call(request)
|
18
|
+
method = request["method"]
|
19
|
+
|
20
|
+
# Skip auth for unprotected methods
|
21
|
+
return @app.call(request) unless @protected_methods.include?(method)
|
22
|
+
|
23
|
+
# Extract token from request
|
24
|
+
provided_token = extract_token(request)
|
25
|
+
|
26
|
+
# Validate token
|
27
|
+
unless valid_token?(provided_token)
|
28
|
+
return unauthorized_response(request["id"])
|
29
|
+
end
|
30
|
+
|
31
|
+
# Continue with request
|
32
|
+
@app.call(request)
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def extract_token(request)
|
38
|
+
# Look in multiple places
|
39
|
+
request.dig("params", "_token") ||
|
40
|
+
request.dig("params", "authorization") ||
|
41
|
+
request.dig("meta", "authorization")
|
42
|
+
end
|
43
|
+
|
44
|
+
def valid_token?(token)
|
45
|
+
return false unless token
|
46
|
+
# Constant-time comparison
|
47
|
+
ActiveSupport::SecurityUtils.secure_compare(token, @token)
|
48
|
+
rescue
|
49
|
+
# Fallback for non-Rails environments
|
50
|
+
token == @token
|
51
|
+
end
|
52
|
+
|
53
|
+
def unauthorized_response(id)
|
54
|
+
{
|
55
|
+
jsonrpc: "2.0",
|
56
|
+
id: id,
|
57
|
+
error: {
|
58
|
+
code: -32001,
|
59
|
+
message: "Unauthorized",
|
60
|
+
data: { details: "Invalid or missing authentication token" }
|
61
|
+
}
|
62
|
+
}
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Usage in server.rb
|
67
|
+
Tsikol.start(name: "secure-server") do
|
68
|
+
use TokenAuthMiddleware, token: ENV['AUTH_TOKEN']
|
69
|
+
|
70
|
+
tool SecureTool
|
71
|
+
resource SecureResource
|
72
|
+
end
|
73
|
+
```
|
74
|
+
|
75
|
+
## JWT Authentication
|
76
|
+
|
77
|
+
### JWT Middleware with User Context
|
78
|
+
|
79
|
+
```ruby
|
80
|
+
require 'jwt'
|
81
|
+
|
82
|
+
class JWTAuthMiddleware < Tsikol::Middleware
|
83
|
+
def initialize(app, options = {})
|
84
|
+
@app = app
|
85
|
+
@secret = options[:secret] || ENV['JWT_SECRET']
|
86
|
+
@algorithm = options[:algorithm] || 'HS256'
|
87
|
+
@skip_methods = options[:skip_methods] || ["initialize"]
|
88
|
+
end
|
89
|
+
|
90
|
+
def call(request)
|
91
|
+
method = request["method"]
|
92
|
+
|
93
|
+
# Skip auth for certain methods
|
94
|
+
return @app.call(request) if @skip_methods.include?(method)
|
95
|
+
|
96
|
+
# Extract and verify JWT
|
97
|
+
token = extract_bearer_token(request)
|
98
|
+
|
99
|
+
begin
|
100
|
+
payload = JWT.decode(token, @secret, true, algorithm: @algorithm)[0]
|
101
|
+
|
102
|
+
# Check expiration
|
103
|
+
if payload['exp'] && Time.now.to_i > payload['exp']
|
104
|
+
return token_expired_response(request["id"])
|
105
|
+
end
|
106
|
+
|
107
|
+
# Add user context to request
|
108
|
+
request["authenticated_user"] = payload
|
109
|
+
|
110
|
+
# Continue with request
|
111
|
+
@app.call(request)
|
112
|
+
|
113
|
+
rescue JWT::DecodeError => e
|
114
|
+
invalid_token_response(request["id"], e.message)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
private
|
119
|
+
|
120
|
+
def extract_bearer_token(request)
|
121
|
+
auth_header = request.dig("params", "authorization") ||
|
122
|
+
request.dig("meta", "authorization") || ""
|
123
|
+
|
124
|
+
# Extract token from "Bearer <token>" format
|
125
|
+
auth_header.sub(/^Bearer /i, '')
|
126
|
+
end
|
127
|
+
|
128
|
+
def token_expired_response(id)
|
129
|
+
{
|
130
|
+
jsonrpc: "2.0",
|
131
|
+
id: id,
|
132
|
+
error: {
|
133
|
+
code: -32002,
|
134
|
+
message: "Token expired",
|
135
|
+
data: { details: "Authentication token has expired" }
|
136
|
+
}
|
137
|
+
}
|
138
|
+
end
|
139
|
+
|
140
|
+
def invalid_token_response(id, details)
|
141
|
+
{
|
142
|
+
jsonrpc: "2.0",
|
143
|
+
id: id,
|
144
|
+
error: {
|
145
|
+
code: -32001,
|
146
|
+
message: "Invalid token",
|
147
|
+
data: { details: details }
|
148
|
+
}
|
149
|
+
}
|
150
|
+
end
|
151
|
+
end
|
152
|
+
```
|
153
|
+
|
154
|
+
### Using User Context in Tools
|
155
|
+
|
156
|
+
```ruby
|
157
|
+
class UserAwareTool < Tsikol::Tool
|
158
|
+
description "Tool that uses authenticated user information"
|
159
|
+
|
160
|
+
parameter :action do
|
161
|
+
type :string
|
162
|
+
required
|
163
|
+
end
|
164
|
+
|
165
|
+
def execute(action:)
|
166
|
+
# Access authenticated user from request context
|
167
|
+
user = Thread.current[:mcp_request]&.dig("authenticated_user")
|
168
|
+
|
169
|
+
unless user
|
170
|
+
return "Authentication required"
|
171
|
+
end
|
172
|
+
|
173
|
+
case action
|
174
|
+
when "profile"
|
175
|
+
get_user_profile(user)
|
176
|
+
when "preferences"
|
177
|
+
get_user_preferences(user)
|
178
|
+
else
|
179
|
+
"Unknown action"
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
private
|
184
|
+
|
185
|
+
def get_user_profile(user)
|
186
|
+
{
|
187
|
+
id: user['sub'],
|
188
|
+
email: user['email'],
|
189
|
+
name: user['name'],
|
190
|
+
roles: user['roles'] || []
|
191
|
+
}.to_json
|
192
|
+
end
|
193
|
+
|
194
|
+
def get_user_preferences(user)
|
195
|
+
# Load preferences based on user ID
|
196
|
+
preferences = load_preferences(user['sub'])
|
197
|
+
preferences.to_json
|
198
|
+
end
|
199
|
+
end
|
200
|
+
```
|
201
|
+
|
202
|
+
## API Key Authentication
|
203
|
+
|
204
|
+
### Multi-Tenant API Key System
|
205
|
+
|
206
|
+
```ruby
|
207
|
+
class APIKeyAuthMiddleware < Tsikol::Middleware
|
208
|
+
def initialize(app, options = {})
|
209
|
+
@app = app
|
210
|
+
@key_store = options[:key_store] || APIKeyStore.new
|
211
|
+
@rate_limiter = options[:rate_limiter]
|
212
|
+
end
|
213
|
+
|
214
|
+
def call(request)
|
215
|
+
api_key = extract_api_key(request)
|
216
|
+
|
217
|
+
unless api_key
|
218
|
+
return missing_key_response(request["id"])
|
219
|
+
end
|
220
|
+
|
221
|
+
# Validate API key
|
222
|
+
key_info = @key_store.validate(api_key)
|
223
|
+
|
224
|
+
unless key_info
|
225
|
+
return invalid_key_response(request["id"])
|
226
|
+
end
|
227
|
+
|
228
|
+
# Check if key is active
|
229
|
+
unless key_info[:active]
|
230
|
+
return inactive_key_response(request["id"])
|
231
|
+
end
|
232
|
+
|
233
|
+
# Apply rate limiting per API key
|
234
|
+
if @rate_limiter && @rate_limiter.exceeded?(api_key)
|
235
|
+
return rate_limit_response(request["id"])
|
236
|
+
end
|
237
|
+
|
238
|
+
# Add tenant context
|
239
|
+
request["tenant"] = key_info[:tenant]
|
240
|
+
request["api_key_id"] = key_info[:id]
|
241
|
+
request["permissions"] = key_info[:permissions]
|
242
|
+
|
243
|
+
# Log API key usage
|
244
|
+
log_usage(api_key, request["method"])
|
245
|
+
|
246
|
+
@app.call(request)
|
247
|
+
end
|
248
|
+
|
249
|
+
private
|
250
|
+
|
251
|
+
def extract_api_key(request)
|
252
|
+
request.dig("params", "api_key") ||
|
253
|
+
request.dig("meta", "x-api-key")
|
254
|
+
end
|
255
|
+
|
256
|
+
def log_usage(api_key, method)
|
257
|
+
# Log API key usage for analytics
|
258
|
+
Thread.new do
|
259
|
+
@key_store.log_usage(api_key, method, Time.now)
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
class APIKeyStore
|
265
|
+
def initialize
|
266
|
+
@keys = load_keys_from_database
|
267
|
+
end
|
268
|
+
|
269
|
+
def validate(api_key)
|
270
|
+
key_hash = hash_key(api_key)
|
271
|
+
@keys[key_hash]
|
272
|
+
end
|
273
|
+
|
274
|
+
def log_usage(api_key, method, timestamp)
|
275
|
+
# Store usage metrics
|
276
|
+
end
|
277
|
+
|
278
|
+
private
|
279
|
+
|
280
|
+
def hash_key(api_key)
|
281
|
+
# Use secure hashing
|
282
|
+
Digest::SHA256.hexdigest(api_key)
|
283
|
+
end
|
284
|
+
|
285
|
+
def load_keys_from_database
|
286
|
+
# Load from your database
|
287
|
+
# Example structure:
|
288
|
+
{
|
289
|
+
"hashed_key_1" => {
|
290
|
+
id: "key_123",
|
291
|
+
tenant: "customer_1",
|
292
|
+
active: true,
|
293
|
+
permissions: ["read", "write"],
|
294
|
+
created_at: Time.now - 86400 * 30
|
295
|
+
}
|
296
|
+
}
|
297
|
+
end
|
298
|
+
end
|
299
|
+
```
|
300
|
+
|
301
|
+
## OAuth 2.0 Integration
|
302
|
+
|
303
|
+
### OAuth Middleware
|
304
|
+
|
305
|
+
```ruby
|
306
|
+
require 'oauth2'
|
307
|
+
|
308
|
+
class OAuthMiddleware < Tsikol::Middleware
|
309
|
+
def initialize(app, options = {})
|
310
|
+
@app = app
|
311
|
+
@client_id = options[:client_id] || ENV['OAUTH_CLIENT_ID']
|
312
|
+
@client_secret = options[:client_secret] || ENV['OAUTH_CLIENT_SECRET']
|
313
|
+
@site = options[:site] || 'https://auth.example.com'
|
314
|
+
@token_url = options[:token_url] || '/oauth/token'
|
315
|
+
@authorize_url = options[:authorize_url] || '/oauth/authorize'
|
316
|
+
end
|
317
|
+
|
318
|
+
def call(request)
|
319
|
+
token = extract_oauth_token(request)
|
320
|
+
|
321
|
+
unless token
|
322
|
+
return unauthorized_response(request["id"], "Missing OAuth token")
|
323
|
+
end
|
324
|
+
|
325
|
+
# Validate token with OAuth provider
|
326
|
+
user_info = validate_token(token)
|
327
|
+
|
328
|
+
unless user_info
|
329
|
+
return unauthorized_response(request["id"], "Invalid OAuth token")
|
330
|
+
end
|
331
|
+
|
332
|
+
# Add user info to request
|
333
|
+
request["oauth_user"] = user_info
|
334
|
+
|
335
|
+
@app.call(request)
|
336
|
+
rescue OAuth2::Error => e
|
337
|
+
unauthorized_response(request["id"], e.message)
|
338
|
+
end
|
339
|
+
|
340
|
+
private
|
341
|
+
|
342
|
+
def oauth_client
|
343
|
+
@oauth_client ||= OAuth2::Client.new(
|
344
|
+
@client_id,
|
345
|
+
@client_secret,
|
346
|
+
site: @site,
|
347
|
+
token_url: @token_url,
|
348
|
+
authorize_url: @authorize_url
|
349
|
+
)
|
350
|
+
end
|
351
|
+
|
352
|
+
def validate_token(token_string)
|
353
|
+
token = OAuth2::AccessToken.new(oauth_client, token_string)
|
354
|
+
|
355
|
+
# Validate token and get user info
|
356
|
+
response = token.get('/api/userinfo')
|
357
|
+
|
358
|
+
if response.status == 200
|
359
|
+
JSON.parse(response.body)
|
360
|
+
else
|
361
|
+
nil
|
362
|
+
end
|
363
|
+
end
|
364
|
+
|
365
|
+
def extract_oauth_token(request)
|
366
|
+
auth_header = request.dig("params", "authorization") || ""
|
367
|
+
auth_header.sub(/^Bearer /i, '')
|
368
|
+
end
|
369
|
+
end
|
370
|
+
```
|
371
|
+
|
372
|
+
## Permission-Based Access Control
|
373
|
+
|
374
|
+
### Role-Based Access Control (RBAC)
|
375
|
+
|
376
|
+
```ruby
|
377
|
+
class RBACMiddleware < Tsikol::Middleware
|
378
|
+
def initialize(app, options = {})
|
379
|
+
@app = app
|
380
|
+
@permissions = options[:permissions] || default_permissions
|
381
|
+
end
|
382
|
+
|
383
|
+
def call(request)
|
384
|
+
method = request["method"]
|
385
|
+
|
386
|
+
# Get required permission for method
|
387
|
+
required_permission = get_required_permission(method)
|
388
|
+
|
389
|
+
# No permission required
|
390
|
+
return @app.call(request) unless required_permission
|
391
|
+
|
392
|
+
# Get user from previous auth middleware
|
393
|
+
user = request["authenticated_user"]
|
394
|
+
|
395
|
+
unless user
|
396
|
+
return unauthorized_response(request["id"], "Authentication required")
|
397
|
+
end
|
398
|
+
|
399
|
+
# Check user permissions
|
400
|
+
user_permissions = get_user_permissions(user)
|
401
|
+
|
402
|
+
unless has_permission?(user_permissions, required_permission)
|
403
|
+
return forbidden_response(request["id"], required_permission)
|
404
|
+
end
|
405
|
+
|
406
|
+
@app.call(request)
|
407
|
+
end
|
408
|
+
|
409
|
+
private
|
410
|
+
|
411
|
+
def default_permissions
|
412
|
+
{
|
413
|
+
"tools/list" => "tools:read",
|
414
|
+
"tools/call" => "tools:execute",
|
415
|
+
"resources/list" => "resources:read",
|
416
|
+
"resources/read" => "resources:read",
|
417
|
+
"prompts/list" => "prompts:read",
|
418
|
+
"prompts/get" => "prompts:use"
|
419
|
+
}
|
420
|
+
end
|
421
|
+
|
422
|
+
def get_required_permission(method)
|
423
|
+
# Check exact match first
|
424
|
+
return @permissions[method] if @permissions[method]
|
425
|
+
|
426
|
+
# Check wildcard patterns
|
427
|
+
@permissions.each do |pattern, permission|
|
428
|
+
if pattern.include?("*") && method.match?(pattern.gsub("*", ".*"))
|
429
|
+
return permission
|
430
|
+
end
|
431
|
+
end
|
432
|
+
|
433
|
+
nil
|
434
|
+
end
|
435
|
+
|
436
|
+
def get_user_permissions(user)
|
437
|
+
# Get permissions from user object or load from database
|
438
|
+
permissions = user["permissions"] || []
|
439
|
+
|
440
|
+
# Add role-based permissions
|
441
|
+
if user["roles"]
|
442
|
+
user["roles"].each do |role|
|
443
|
+
permissions.concat(role_permissions(role))
|
444
|
+
end
|
445
|
+
end
|
446
|
+
|
447
|
+
permissions.uniq
|
448
|
+
end
|
449
|
+
|
450
|
+
def role_permissions(role)
|
451
|
+
# Define role-based permissions
|
452
|
+
case role
|
453
|
+
when "admin"
|
454
|
+
["tools:*", "resources:*", "prompts:*"]
|
455
|
+
when "developer"
|
456
|
+
["tools:execute", "resources:read", "prompts:use"]
|
457
|
+
when "viewer"
|
458
|
+
["tools:read", "resources:read", "prompts:read"]
|
459
|
+
else
|
460
|
+
[]
|
461
|
+
end
|
462
|
+
end
|
463
|
+
|
464
|
+
def has_permission?(user_permissions, required)
|
465
|
+
user_permissions.any? do |perm|
|
466
|
+
# Exact match
|
467
|
+
perm == required ||
|
468
|
+
# Wildcard match (e.g., "tools:*" matches "tools:execute")
|
469
|
+
(perm.end_with?("*") && required.start_with?(perm[0..-2]))
|
470
|
+
end
|
471
|
+
end
|
472
|
+
|
473
|
+
def forbidden_response(id, permission)
|
474
|
+
{
|
475
|
+
jsonrpc: "2.0",
|
476
|
+
id: id,
|
477
|
+
error: {
|
478
|
+
code: -32003,
|
479
|
+
message: "Forbidden",
|
480
|
+
data: {
|
481
|
+
details: "Insufficient permissions",
|
482
|
+
required: permission
|
483
|
+
}
|
484
|
+
}
|
485
|
+
}
|
486
|
+
end
|
487
|
+
end
|
488
|
+
```
|
489
|
+
|
490
|
+
## Secure Configuration
|
491
|
+
|
492
|
+
### Environment-Based Security
|
493
|
+
|
494
|
+
```ruby
|
495
|
+
# config/security.rb
|
496
|
+
module Security
|
497
|
+
class Config
|
498
|
+
def self.load
|
499
|
+
{
|
500
|
+
auth_enabled: ENV.fetch('MCP_AUTH_ENABLED', 'true') == 'true',
|
501
|
+
auth_type: ENV.fetch('MCP_AUTH_TYPE', 'jwt'),
|
502
|
+
jwt_secret: ENV.fetch('JWT_SECRET') { raise "JWT_SECRET required" },
|
503
|
+
jwt_algorithm: ENV.fetch('JWT_ALGORITHM', 'HS256'),
|
504
|
+
token_expiry: ENV.fetch('TOKEN_EXPIRY', '3600').to_i,
|
505
|
+
require_https: ENV.fetch('REQUIRE_HTTPS', 'true') == 'true',
|
506
|
+
allowed_origins: ENV.fetch('ALLOWED_ORIGINS', '*').split(','),
|
507
|
+
rate_limit: {
|
508
|
+
enabled: ENV.fetch('RATE_LIMIT_ENABLED', 'true') == 'true',
|
509
|
+
max_requests: ENV.fetch('RATE_LIMIT_MAX', '100').to_i,
|
510
|
+
window_seconds: ENV.fetch('RATE_LIMIT_WINDOW', '60').to_i
|
511
|
+
}
|
512
|
+
}
|
513
|
+
end
|
514
|
+
end
|
515
|
+
end
|
516
|
+
|
517
|
+
# server.rb
|
518
|
+
require_relative 'config/security'
|
519
|
+
|
520
|
+
Tsikol.start(name: "secure-server") do
|
521
|
+
config = Security::Config.load
|
522
|
+
|
523
|
+
if config[:auth_enabled]
|
524
|
+
case config[:auth_type]
|
525
|
+
when 'jwt'
|
526
|
+
use JWTAuthMiddleware,
|
527
|
+
secret: config[:jwt_secret],
|
528
|
+
algorithm: config[:jwt_algorithm]
|
529
|
+
when 'api_key'
|
530
|
+
use APIKeyAuthMiddleware
|
531
|
+
when 'oauth'
|
532
|
+
use OAuthMiddleware
|
533
|
+
end
|
534
|
+
|
535
|
+
# Add RBAC after authentication
|
536
|
+
use RBACMiddleware
|
537
|
+
end
|
538
|
+
|
539
|
+
if config[:rate_limit][:enabled]
|
540
|
+
use Tsikol::RateLimitMiddleware,
|
541
|
+
max_requests: config[:rate_limit][:max_requests],
|
542
|
+
window_seconds: config[:rate_limit][:window_seconds]
|
543
|
+
end
|
544
|
+
|
545
|
+
# Your tools and resources
|
546
|
+
tool SecureTool
|
547
|
+
resource SecureResource
|
548
|
+
end
|
549
|
+
```
|
550
|
+
|
551
|
+
## Testing Authentication
|
552
|
+
|
553
|
+
```ruby
|
554
|
+
require 'minitest/autorun'
|
555
|
+
require 'jwt'
|
556
|
+
|
557
|
+
class AuthenticationTest < Minitest::Test
|
558
|
+
def setup
|
559
|
+
@server = create_secure_server
|
560
|
+
@client = Tsikol::TestHelpers::TestClient.new(@server)
|
561
|
+
end
|
562
|
+
|
563
|
+
def test_requires_authentication
|
564
|
+
response = @client.call_tool("secure_tool", {})
|
565
|
+
assert_error_response(response, -32001)
|
566
|
+
assert_match /unauthorized/i, response[:error][:message]
|
567
|
+
end
|
568
|
+
|
569
|
+
def test_accepts_valid_token
|
570
|
+
token = generate_valid_jwt
|
571
|
+
|
572
|
+
response = @client.call_tool("secure_tool", {
|
573
|
+
"_token" => token,
|
574
|
+
"action" => "test"
|
575
|
+
})
|
576
|
+
|
577
|
+
assert_successful_response(response)
|
578
|
+
end
|
579
|
+
|
580
|
+
def test_rejects_expired_token
|
581
|
+
token = generate_expired_jwt
|
582
|
+
|
583
|
+
response = @client.call_tool("secure_tool", {
|
584
|
+
"_token" => token,
|
585
|
+
"action" => "test"
|
586
|
+
})
|
587
|
+
|
588
|
+
assert_error_response(response, -32002)
|
589
|
+
assert_match /expired/i, response[:error][:message]
|
590
|
+
end
|
591
|
+
|
592
|
+
def test_enforces_permissions
|
593
|
+
# User with limited permissions
|
594
|
+
token = generate_jwt_with_permissions(["tools:read"])
|
595
|
+
|
596
|
+
response = @client.call_tool("secure_tool", {
|
597
|
+
"_token" => token,
|
598
|
+
"action" => "write" # Requires tools:execute
|
599
|
+
})
|
600
|
+
|
601
|
+
assert_error_response(response, -32003)
|
602
|
+
assert_match /forbidden/i, response[:error][:message]
|
603
|
+
end
|
604
|
+
|
605
|
+
private
|
606
|
+
|
607
|
+
def create_secure_server
|
608
|
+
server = Tsikol::Server.new(name: "test")
|
609
|
+
server.use JWTAuthMiddleware, secret: "test_secret"
|
610
|
+
server.use RBACMiddleware
|
611
|
+
server.register_tool_instance(SecureTool.new)
|
612
|
+
server
|
613
|
+
end
|
614
|
+
|
615
|
+
def generate_valid_jwt
|
616
|
+
payload = {
|
617
|
+
sub: "user123",
|
618
|
+
email: "user@example.com",
|
619
|
+
permissions: ["tools:execute"],
|
620
|
+
exp: Time.now.to_i + 3600
|
621
|
+
}
|
622
|
+
JWT.encode(payload, "test_secret", 'HS256')
|
623
|
+
end
|
624
|
+
|
625
|
+
def generate_expired_jwt
|
626
|
+
payload = {
|
627
|
+
sub: "user123",
|
628
|
+
exp: Time.now.to_i - 3600 # Expired 1 hour ago
|
629
|
+
}
|
630
|
+
JWT.encode(payload, "test_secret", 'HS256')
|
631
|
+
end
|
632
|
+
end
|
633
|
+
```
|
634
|
+
|
635
|
+
## Security Best Practices
|
636
|
+
|
637
|
+
1. **Always use HTTPS** in production
|
638
|
+
2. **Store secrets securely** using environment variables or secret management systems
|
639
|
+
3. **Implement rate limiting** to prevent abuse
|
640
|
+
4. **Log authentication attempts** for security monitoring
|
641
|
+
5. **Use secure token storage** with proper expiration
|
642
|
+
6. **Implement CSRF protection** for web-based clients
|
643
|
+
7. **Regular security audits** of your authentication system
|
644
|
+
8. **Principle of least privilege** for permissions
|
645
|
+
|
646
|
+
## Next Steps
|
647
|
+
|
648
|
+
- Implement [Rate Limiting](rate-limiting.md)
|
649
|
+
- Add [Logging](logging.md) for security events
|
650
|
+
- Set up [Error Handling](error-handling.md)
|
651
|
+
- Review [Testing Guide](../guides/testing.md)
|