aris 1.4.2 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +251 -0
- data/README.md +18 -0
- data/docs/ADAPTERS.md +478 -0
- data/docs/ARCHITECTURE.md +222 -0
- data/docs/CONTENT.md +967 -0
- data/docs/PERFORMANCE.md +492 -0
- data/docs/PLUGIN_DEVELOPMENT.md +688 -0
- data/docs/USAGE.md +4998 -0
- data/docs/plugins/API_KEY_AUTH.md +232 -0
- data/docs/plugins/BASIC_AUTH.md +582 -0
- data/docs/plugins/BEARER_AUTH.md +394 -0
- data/docs/plugins/CACHE.md +369 -0
- data/docs/plugins/COMPRESSION.md +216 -0
- data/docs/plugins/COOKIES.md +30 -0
- data/docs/plugins/CORS.md +283 -0
- data/docs/plugins/CSRF.md +751 -0
- data/docs/plugins/ETAG.md +308 -0
- data/docs/plugins/FORM_PARSER.md +193 -0
- data/docs/plugins/HEALTH_CHECK.md +469 -0
- data/docs/plugins/JSON.md +291 -0
- data/docs/plugins/MULTIPART.md +427 -0
- data/docs/plugins/RATE_LIMITER.md +368 -0
- data/docs/plugins/REQUEST_ID.md +369 -0
- data/docs/plugins/REQUEST_LOGGER.md +151 -0
- data/docs/plugins/SECURITY.md +193 -0
- data/docs/plugins/SESSION.md +98 -0
- data/lib/aris/adapters/rack/adapter.rb +17 -2
- data/lib/aris/adapters/rack/request.rb +29 -11
- data/lib/aris/plugins/basic_auth.rb +3 -1
- data/lib/aris/plugins/cookies.rb +4 -32
- data/lib/aris/plugins/cors.rb +8 -1
- data/lib/aris/plugins/csrf.rb +63 -22
- data/lib/aris/plugins/flash.rb +3 -1
- data/lib/aris/plugins/form_parser.rb +52 -31
- data/lib/aris/plugins/multipart.rb +22 -2
- data/lib/aris/plugins/request_logger.rb +8 -1
- data/lib/aris/plugins/security_headers.rb +8 -1
- data/lib/aris/plugins/session.rb +150 -99
- data/lib/aris/response_helpers.rb +41 -0
- data/lib/aris/version.rb +2 -2
- metadata +31 -3
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# Bearer Token Authentication Plugin
|
|
2
|
+
|
|
3
|
+
Dead-simple Bearer token authentication for API endpoints.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# lib/aris.rb already includes this
|
|
9
|
+
require_relative 'aris/plugins/bearer_auth'
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Basic Usage
|
|
13
|
+
|
|
14
|
+
### Simple Token Validation
|
|
15
|
+
|
|
16
|
+
Perfect for internal APIs or development:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
# config/routes.rb
|
|
20
|
+
api_auth = Aris::Plugins::BearerAuth.build(
|
|
21
|
+
token: ENV['API_SECRET_TOKEN']
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
Aris.routes({
|
|
25
|
+
"api.example.com": {
|
|
26
|
+
use: [api_auth],
|
|
27
|
+
"/users": { get: { to: UsersHandler } },
|
|
28
|
+
"/posts": { get: { to: PostsHandler } }
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Request:**
|
|
34
|
+
```bash
|
|
35
|
+
curl -H "Authorization: Bearer your-secret-token" \
|
|
36
|
+
https://api.example.com/users
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Advanced Usage
|
|
42
|
+
|
|
43
|
+
### Database Token Validation
|
|
44
|
+
|
|
45
|
+
Validate against your user/token database:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
api_auth = Aris::Plugins::BearerAuth.build(
|
|
49
|
+
validator: ->(token) {
|
|
50
|
+
# Check if token exists and is valid
|
|
51
|
+
api_key = ApiKey.find_by(token: token, active: true)
|
|
52
|
+
|
|
53
|
+
if api_key && !api_key.expired?
|
|
54
|
+
# Optional: Track usage
|
|
55
|
+
api_key.update(last_used_at: Time.now)
|
|
56
|
+
true
|
|
57
|
+
else
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
},
|
|
61
|
+
realm: 'MyApp API v1'
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
Aris.routes({
|
|
65
|
+
"api.example.com": {
|
|
66
|
+
use: [api_auth],
|
|
67
|
+
"/data": { get: { to: DataHandler } }
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
### Redis Token Store
|
|
75
|
+
|
|
76
|
+
For high-performance token validation:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
require 'redis'
|
|
80
|
+
REDIS = Redis.new
|
|
81
|
+
|
|
82
|
+
api_auth = Aris::Plugins::BearerAuth.build(
|
|
83
|
+
validator: ->(token) {
|
|
84
|
+
# Check Redis cache first (fast!)
|
|
85
|
+
cached = REDIS.get("token:#{token}")
|
|
86
|
+
return cached == "valid" if cached
|
|
87
|
+
|
|
88
|
+
# Fallback to database
|
|
89
|
+
user = User.find_by(api_token: token)
|
|
90
|
+
if user
|
|
91
|
+
REDIS.setex("token:#{token}", 3600, "valid") # Cache for 1 hour
|
|
92
|
+
true
|
|
93
|
+
else
|
|
94
|
+
REDIS.setex("token:#{token}", 300, "invalid") # Cache misses too
|
|
95
|
+
false
|
|
96
|
+
end
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
### JWT Token Validation
|
|
104
|
+
|
|
105
|
+
Integrate with JWT for stateless authentication:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
require 'jwt'
|
|
109
|
+
|
|
110
|
+
jwt_auth = Aris::Plugins::BearerAuth.build(
|
|
111
|
+
validator: ->(token) {
|
|
112
|
+
begin
|
|
113
|
+
payload = JWT.decode(token, ENV['JWT_SECRET'], true, algorithm: 'HS256')
|
|
114
|
+
|
|
115
|
+
# Check expiration
|
|
116
|
+
exp = payload[0]['exp']
|
|
117
|
+
exp && exp > Time.now.to_i
|
|
118
|
+
rescue JWT::DecodeError, JWT::ExpiredSignature
|
|
119
|
+
false
|
|
120
|
+
end
|
|
121
|
+
},
|
|
122
|
+
realm: 'JWT API'
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
Aris.routes({
|
|
126
|
+
"api.example.com": {
|
|
127
|
+
use: [jwt_auth],
|
|
128
|
+
"/protected": { get: { to: ProtectedHandler } }
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Multiple Authentication Strategies
|
|
136
|
+
|
|
137
|
+
Different endpoints, different tokens:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
# Admin API - super secret token
|
|
141
|
+
admin_auth = Aris::Plugins::BearerAuth.build(
|
|
142
|
+
token: ENV['ADMIN_SECRET'],
|
|
143
|
+
realm: 'Admin Panel'
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Public API - database validation
|
|
147
|
+
public_auth = Aris::Plugins::BearerAuth.build(
|
|
148
|
+
validator: ->(token) { ApiKey.valid?(token) },
|
|
149
|
+
realm: 'Public API'
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Partner API - partner-specific tokens
|
|
153
|
+
partner_auth = Aris::Plugins::BearerAuth.build(
|
|
154
|
+
validator: ->(token) {
|
|
155
|
+
Partner.find_by(api_key: token, status: 'active')
|
|
156
|
+
},
|
|
157
|
+
realm: 'Partner Integration'
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
Aris.routes({
|
|
161
|
+
"admin.myapp.com": {
|
|
162
|
+
use: [admin_auth],
|
|
163
|
+
"/dashboard": { get: { to: AdminDashboard } }
|
|
164
|
+
},
|
|
165
|
+
"api.myapp.com": {
|
|
166
|
+
use: [public_auth],
|
|
167
|
+
"/users": { get: { to: UsersAPI } }
|
|
168
|
+
},
|
|
169
|
+
"partner.myapp.com": {
|
|
170
|
+
use: [partner_auth],
|
|
171
|
+
"/webhooks": { post: { to: WebhookHandler } }
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Accessing the Token in Handlers
|
|
179
|
+
|
|
180
|
+
The validated token is attached to the request:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
class UsersHandler
|
|
184
|
+
def self.call(request, params)
|
|
185
|
+
# Access the bearer token
|
|
186
|
+
token = request.instance_variable_get(:@bearer_token)
|
|
187
|
+
|
|
188
|
+
# Use it to identify the user
|
|
189
|
+
api_key = ApiKey.find_by(token: token)
|
|
190
|
+
user = api_key.user
|
|
191
|
+
|
|
192
|
+
{
|
|
193
|
+
message: "Hello, #{user.name}!",
|
|
194
|
+
token_expires: api_key.expires_at
|
|
195
|
+
}
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Combining with Other Plugins
|
|
203
|
+
|
|
204
|
+
Layer authentication with rate limiting and CORS:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
cors = Aris::Plugins::Cors.build(origins: ['https://app.example.com'])
|
|
208
|
+
auth = Aris::Plugins::BearerAuth.build(validator: ->(t) { ApiKey.valid?(t) })
|
|
209
|
+
rate_limit = Aris::Plugins::RateLimiter.build(limit: 1000, window: 3600)
|
|
210
|
+
|
|
211
|
+
Aris.routes({
|
|
212
|
+
"api.example.com": {
|
|
213
|
+
use: [cors, auth, rate_limit], # Execute in order
|
|
214
|
+
"/data": { get: { to: DataHandler } }
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
**Execution order matters:**
|
|
220
|
+
1. CORS headers set first (for preflight)
|
|
221
|
+
2. Auth validates token (fails fast if invalid)
|
|
222
|
+
3. Rate limiter only runs for authenticated users (saves Redis calls)
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Configuration Options
|
|
227
|
+
|
|
228
|
+
| Option | Type | Required | Description |
|
|
229
|
+
|:---|:---|:---|:---|
|
|
230
|
+
| `token` | String | * | Static token to validate against |
|
|
231
|
+
| `validator` | Proc | * | Custom validation logic `(token) -> Boolean` |
|
|
232
|
+
| `realm` | String | No | Realm for WWW-Authenticate header (default: "API") |
|
|
233
|
+
|
|
234
|
+
**Note:** Must provide either `token` OR `validator`, not both.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Error Responses
|
|
239
|
+
|
|
240
|
+
All errors return JSON with consistent format:
|
|
241
|
+
|
|
242
|
+
**401 Unauthorized:**
|
|
243
|
+
```json
|
|
244
|
+
{
|
|
245
|
+
"error": "Unauthorized",
|
|
246
|
+
"message": "Invalid or expired token"
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
**Headers:**
|
|
251
|
+
```
|
|
252
|
+
HTTP/1.1 401 Unauthorized
|
|
253
|
+
content-type: application/json
|
|
254
|
+
WWW-Authenticate: Bearer realm="API"
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## Production Tips
|
|
260
|
+
|
|
261
|
+
### 1. Use Environment Variables
|
|
262
|
+
```ruby
|
|
263
|
+
# NEVER hardcode tokens in source code
|
|
264
|
+
auth = Aris::Plugins::BearerAuth.build(
|
|
265
|
+
token: ENV.fetch('API_SECRET_TOKEN') # Fails fast if missing
|
|
266
|
+
)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### 2. Cache Token Lookups
|
|
270
|
+
```ruby
|
|
271
|
+
# Cache in Redis/Memcached to avoid DB hits
|
|
272
|
+
auth = Aris::Plugins::BearerAuth.build(
|
|
273
|
+
validator: ->(token) {
|
|
274
|
+
Rails.cache.fetch("token:#{token}", expires_in: 5.minutes) do
|
|
275
|
+
ApiKey.exists?(token: token, active: true)
|
|
276
|
+
end
|
|
277
|
+
}
|
|
278
|
+
)
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### 3. Log Failed Attempts
|
|
282
|
+
```ruby
|
|
283
|
+
auth = Aris::Plugins::BearerAuth.build(
|
|
284
|
+
validator: ->(token) {
|
|
285
|
+
valid = ApiKey.valid?(token)
|
|
286
|
+
unless valid
|
|
287
|
+
Rails.logger.warn("Failed auth attempt with token: #{token[0..8]}...")
|
|
288
|
+
Metrics.increment('api.auth.failed')
|
|
289
|
+
end
|
|
290
|
+
valid
|
|
291
|
+
}
|
|
292
|
+
)
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### 4. Rate Limit by Token
|
|
296
|
+
```ruby
|
|
297
|
+
# Combine with rate limiter for per-token limits
|
|
298
|
+
token_rate_limit = Aris::Plugins::RateLimiter.build(
|
|
299
|
+
key_extractor: ->(request) {
|
|
300
|
+
request.instance_variable_get(:@bearer_token)
|
|
301
|
+
}
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
Aris.routes({
|
|
305
|
+
"api.example.com": {
|
|
306
|
+
use: [auth, token_rate_limit], # Auth first, then rate limit by token
|
|
307
|
+
"/data": { get: { to: DataHandler } }
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## Testing
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
# test/integration/api_auth_test.rb
|
|
318
|
+
class ApiAuthTest < Minitest::Test
|
|
319
|
+
def test_valid_token_grants_access
|
|
320
|
+
auth = Aris::Plugins::BearerAuth.build(token: 'test-token-123')
|
|
321
|
+
|
|
322
|
+
Aris.routes({
|
|
323
|
+
"api.test": {
|
|
324
|
+
use: [auth],
|
|
325
|
+
"/data": { get: { to: DataHandler } }
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
app = Aris::Adapters::RackApp.new
|
|
330
|
+
env = {
|
|
331
|
+
'REQUEST_METHOD' => 'GET',
|
|
332
|
+
'PATH_INFO' => '/data',
|
|
333
|
+
'HTTP_HOST' => 'api.test',
|
|
334
|
+
'HTTP_AUTHORIZATION' => 'Bearer test-token-123',
|
|
335
|
+
'rack.input' => StringIO.new('')
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
status, _, body = app.call(env)
|
|
339
|
+
assert_equal 200, status
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
## Common Patterns
|
|
347
|
+
|
|
348
|
+
### Health Check Bypass
|
|
349
|
+
```ruby
|
|
350
|
+
Aris.routes({
|
|
351
|
+
"api.example.com": {
|
|
352
|
+
use: [auth],
|
|
353
|
+
|
|
354
|
+
"/users": { get: { to: UsersHandler } },
|
|
355
|
+
|
|
356
|
+
"/health": {
|
|
357
|
+
use: nil, # Clear auth for health checks
|
|
358
|
+
get: { to: HealthHandler }
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
})
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### Scope-Specific Auth
|
|
365
|
+
```ruby
|
|
366
|
+
Aris.routes({
|
|
367
|
+
"example.com": {
|
|
368
|
+
"/public": {
|
|
369
|
+
# No auth
|
|
370
|
+
"/blog": { get: { to: BlogHandler } }
|
|
371
|
+
},
|
|
372
|
+
"/api": {
|
|
373
|
+
use: [api_auth], # Auth only for /api/* routes
|
|
374
|
+
"/users": { get: { to: UsersHandler } }
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
})
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
## Security Notes
|
|
383
|
+
|
|
384
|
+
- ✅ Always use HTTPS in production (tokens sent in headers)
|
|
385
|
+
- ✅ Rotate tokens regularly
|
|
386
|
+
- ✅ Use different tokens for different environments
|
|
387
|
+
- ✅ Log failed authentication attempts
|
|
388
|
+
- ✅ Set short expiration times for JWT tokens
|
|
389
|
+
- ❌ Never log full tokens (log only first 8 chars)
|
|
390
|
+
- ❌ Never commit tokens to version control
|
|
391
|
+
|
|
392
|
+
---
|
|
393
|
+
|
|
394
|
+
Need help? Check out the [full plugin development guide](../docs/plugin-development.md).
|