durable_streams-rails 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +44 -44
- data/lib/durable_streams/rails/engine.rb +15 -17
- data/lib/durable_streams/rails/gatekeeper.rb +87 -0
- data/lib/durable_streams/rails/server_config.rb +32 -33
- data/lib/durable_streams/rails/stream_name.rb +13 -27
- data/lib/durable_streams/rails/version.rb +1 -1
- data/lib/durable_streams-rails.rb +40 -38
- data/lib/generators/durable_streams/install/templates/durable_streams.yml.tt +4 -9
- data/lib/generators/durable_streams/install/templates/initializer.rb.tt +6 -0
- data/lib/tasks/durable_streams.rake +22 -51
- metadata +16 -3
- data/app/controllers/durable_streams/rails/auth_controller.rb +0 -42
- data/config/routes.rb +0 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 29432e07eb91417e49d52901bc6edce8b6b7a706ed219c31c5b2880c40435e61
|
|
4
|
+
data.tar.gz: 0c7c63e0ffa19cad00031672cd9549270e28564d55925365ee0ff7dcaa6c4922
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 938bffc33ac915645afba73a59a02581414b874817983001b56f4ade9e2d9207c651e98e8cb9de9c47c31879c4a51a2ae3192b858f44a927e0ec0e5596797ddf
|
|
7
|
+
data.tar.gz: 501936e24acf94fb01a7712d4e3c30cad277e2f6de84879e490dc285d47013aa4b87ebc84d2dd21db5af476d2cf2c9169d5c07100c896a066af51979a3175810
|
data/README.md
CHANGED
|
@@ -43,42 +43,50 @@ This creates:
|
|
|
43
43
|
bin/rails generate durable_streams:install --version=0.1.0
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
### JWT signing
|
|
47
49
|
|
|
48
|
-
The gem
|
|
49
|
-
|
|
50
|
+
The gem uses asymmetric ES256 JWTs. Rails signs tokens with a private key, Caddy verifies
|
|
51
|
+
with the public key via `ggicci/caddy-jwt` — no callback to Rails.
|
|
50
52
|
|
|
51
53
|
```ruby
|
|
52
54
|
# config/initializers/durable_streams.rb
|
|
53
|
-
Rails.application.config.durable_streams.
|
|
55
|
+
Rails.application.config.durable_streams.signing_key = Rails.application.credentials.dig(:durable_streams, :signing_key)
|
|
56
|
+
Rails.application.config.durable_streams.signing_kid = Rails.application.credentials.dig(:durable_streams, :signing_kid)
|
|
57
|
+
Rails.application.config.durable_streams.token_issuer = "https://your-app.com"
|
|
54
58
|
```
|
|
55
59
|
|
|
56
|
-
|
|
60
|
+
Generate a key pair:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
bin/rails durable_streams:generate_keys
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Server config
|
|
57
67
|
|
|
58
68
|
The server reads `config/durable_streams.yml`:
|
|
59
69
|
|
|
60
70
|
```yaml
|
|
61
71
|
default: &default
|
|
62
72
|
port: 4437
|
|
63
|
-
# route: /v1/streams/*
|
|
64
73
|
auth:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
- Cookie
|
|
74
|
+
verify_key: config/caddy/verify_key.pem
|
|
75
|
+
issuer: https://localhost:3000
|
|
76
|
+
audience: https://localhost:4437/v1/streams
|
|
69
77
|
|
|
70
78
|
development:
|
|
71
79
|
<<: *default
|
|
72
80
|
|
|
73
81
|
production:
|
|
74
|
-
<<: *default
|
|
75
82
|
domain: streams.example.com
|
|
83
|
+
tls:
|
|
84
|
+
cert: /etc/caddy/certs/cert.pem
|
|
85
|
+
key: /etc/caddy/certs/key.pem
|
|
76
86
|
auth:
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
- Cookie
|
|
81
|
-
- Authorization
|
|
87
|
+
verify_key: /etc/durable_streams/verify_key.pem
|
|
88
|
+
issuer: https://your-app.com
|
|
89
|
+
audience: https://streams.example.com/v1/streams
|
|
82
90
|
storage:
|
|
83
91
|
data_dir: /var/data/durable-streams
|
|
84
92
|
```
|
|
@@ -98,25 +106,17 @@ production:
|
|
|
98
106
|
key: /etc/caddy/certs/key.pem
|
|
99
107
|
internal:
|
|
100
108
|
port: 4437
|
|
101
|
-
bind: 10.0.0.5
|
|
102
|
-
allowed_ips:
|
|
109
|
+
bind: 10.0.0.5
|
|
110
|
+
allowed_ips:
|
|
103
111
|
- 10.0.0.4
|
|
104
112
|
auth:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
copy_headers:
|
|
109
|
-
- Cookie
|
|
110
|
-
- Authorization
|
|
113
|
+
verify_key: /etc/durable_streams/verify_key.pem
|
|
114
|
+
issuer: https://your-app.com
|
|
115
|
+
audience: https://streams.example.com/v1/streams
|
|
111
116
|
storage:
|
|
112
117
|
data_dir: /data/streams
|
|
113
118
|
```
|
|
114
119
|
|
|
115
|
-
When `auth.host` is set and the URL is HTTPS, the generated Caddyfile adds
|
|
116
|
-
`header_up Host`, `tls_server_name`, and `tls_insecure_skip_verify` to the auth
|
|
117
|
-
reverse proxy — same pattern as the internal listener's loopback connection. Use this
|
|
118
|
-
when the auth URL is an internal IP and the Kamal proxy routes by Host header.
|
|
119
|
-
|
|
120
120
|
#### Request flow
|
|
121
121
|
|
|
122
122
|
A server-to-server write (e.g., Rails broadcasting a message) follows this path:
|
|
@@ -148,7 +148,7 @@ Rails (10.0.0.4)
|
|
|
148
148
|
▼
|
|
149
149
|
streams.example.com :443 listener (same Caddy process)
|
|
150
150
|
│
|
|
151
|
-
│
|
|
151
|
+
│ jwtauth (verifies ES256 JWT locally using public key — no callback to Rails)
|
|
152
152
|
│ durable_streams → writes to bbolt store
|
|
153
153
|
│
|
|
154
154
|
▼
|
|
@@ -376,7 +376,8 @@ the gem doesn't need to know which mode the client will use.
|
|
|
376
376
|
### Access control
|
|
377
377
|
|
|
378
378
|
Only the server may create streams. The server authenticates with the Durable Streams server
|
|
379
|
-
via
|
|
379
|
+
via short-lived ES256 JWT Bearer tokens; clients authenticate via signed, expiring tokens
|
|
380
|
+
generated by this gem.
|
|
380
381
|
|
|
381
382
|
| | Create | Append | Read | Close | Delete | Metadata |
|
|
382
383
|
|---|---|---|---|---|---|---|
|
|
@@ -389,9 +390,9 @@ via API key; clients authenticate via signed, expiring tokens generated by this
|
|
|
389
390
|
|
|
390
391
|
Client append (planned) enables use cases like live cursors and typing indicators where
|
|
391
392
|
the write payload goes directly to the stream server rather than through a Rails controller.
|
|
392
|
-
The Durable Streams server
|
|
393
|
+
The Durable Streams server verifies ES256 JWTs locally via Caddy's jwtauth on every write to verify
|
|
393
394
|
the signed token, but this is lightweight — pure HMAC verification with no database or
|
|
394
|
-
session loading. For reads (SSE),
|
|
395
|
+
session loading. For reads (SSE), JWT verification happens once at connection time.
|
|
395
396
|
|
|
396
397
|
### Stream provisioning
|
|
397
398
|
|
|
@@ -419,7 +420,7 @@ The gem separates client-facing and server-to-server communication:
|
|
|
419
420
|
DurableStreams.client_url = ENV.fetch("DURABLE_STREAMS_CLIENT_URL", "http://localhost:4437/v1/streams")
|
|
420
421
|
|
|
421
422
|
# Server-to-server — used by Rails to POST broadcasts and PUT stream creation.
|
|
422
|
-
#
|
|
423
|
+
# Authenticated via short-lived ES256 JWTs (callable Authorization header).
|
|
423
424
|
DurableStreams.server_url = ENV.fetch("DURABLE_STREAMS_SERVER_URL", "http://localhost:4437/v1/streams")
|
|
424
425
|
```
|
|
425
426
|
|
|
@@ -430,25 +431,24 @@ network address.
|
|
|
430
431
|
`server_url` is never exposed to clients. `client_url` is never used for server-to-server
|
|
431
432
|
communication.
|
|
432
433
|
|
|
433
|
-
### Client authentication — signed URLs
|
|
434
|
+
### Client authentication — ES256 JWT signed URLs
|
|
434
435
|
|
|
435
436
|
Browsers receive signed, expiring URLs via Inertia props (or any server-rendered response).
|
|
436
|
-
The token is
|
|
437
|
-
|
|
437
|
+
The token is an ES256 JWT encoding the stream name, permissions (`read`/`write`), issuer,
|
|
438
|
+
and an expiration timestamp.
|
|
438
439
|
|
|
439
|
-
|
|
440
|
-
|
|
440
|
+
Caddy's `jwtauth` middleware (via `ggicci/caddy-jwt`) verifies the JWT signature against
|
|
441
|
+
the public key PEM file. No callback to Rails — verification is entirely local.
|
|
441
442
|
|
|
442
443
|
**The token is domain-agnostic.** It validates *what* (stream name + permissions + expiry),
|
|
443
444
|
not *where* (which host the request is hitting). Network-level controls (firewall/NSG rules)
|
|
444
445
|
must prevent clients from reaching `server_url` directly.
|
|
445
446
|
|
|
446
|
-
### Server authentication —
|
|
447
|
+
### Server authentication — ES256 JWT Bearer tokens
|
|
447
448
|
|
|
448
|
-
Rails authenticates to the Durable Streams server with
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
without manual configuration.
|
|
449
|
+
Rails authenticates to the Durable Streams server with short-lived ES256 JWTs sent as
|
|
450
|
+
`Authorization: Bearer <JWT>`. A fresh JWT is minted per-request via a callable lambda in
|
|
451
|
+
the engine initializer. The private key is stored in Rails credentials.
|
|
452
452
|
|
|
453
453
|
## Future
|
|
454
454
|
|
|
@@ -4,9 +4,9 @@ module DurableStreams
|
|
|
4
4
|
module Rails
|
|
5
5
|
class Engine < ::Rails::Engine
|
|
6
6
|
isolate_namespace DurableStreams::Rails
|
|
7
|
+
config.eager_load_namespaces << DurableStreams
|
|
7
8
|
config.durable_streams = ActiveSupport::OrderedOptions.new
|
|
8
9
|
config.autoload_once_paths = %W[
|
|
9
|
-
#{root}/app/controllers
|
|
10
10
|
#{root}/app/models
|
|
11
11
|
#{root}/app/models/concerns
|
|
12
12
|
#{root}/app/jobs
|
|
@@ -28,30 +28,28 @@ module DurableStreams
|
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
initializer "durable_streams_rails.
|
|
32
|
-
config.after_initialize do |app|
|
|
33
|
-
DurableStreams.draw_routes = app.config.durable_streams.draw_routes != false
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
initializer "durable_streams_rails.signed_stream_verifier_key" do
|
|
31
|
+
initializer "durable_streams_rails.signing_key" do
|
|
38
32
|
config.after_initialize do
|
|
39
|
-
|
|
40
|
-
config.durable_streams.
|
|
41
|
-
|
|
33
|
+
if config.durable_streams.signing_key
|
|
34
|
+
DurableStreams.signing_key = config.durable_streams.signing_key
|
|
35
|
+
DurableStreams.signing_kid = config.durable_streams.signing_kid
|
|
36
|
+
DurableStreams.token_issuer = config.durable_streams.token_issuer
|
|
37
|
+
DurableStreams.token_expiry = config.durable_streams.token_expiry if config.durable_streams.token_expiry
|
|
38
|
+
end
|
|
42
39
|
end
|
|
43
40
|
end
|
|
44
41
|
|
|
45
|
-
|
|
42
|
+
# Configures the base durable_streams gem (Ruby client) for server-to-server
|
|
43
|
+
# calls (PUT, POST, HEAD) to the stream server. The lambda mints a fresh
|
|
44
|
+
# server JWT per request (120s expiry by default).
|
|
45
|
+
initializer "durable_streams_rails.ruby_client" do
|
|
46
46
|
config.after_initialize do
|
|
47
|
-
DurableStreams.server_api_key =
|
|
48
|
-
config.durable_streams.server_api_key ||
|
|
49
|
-
::Rails.application.key_generator.generate_key("durable_streams/server_api_key").unpack1("H*")
|
|
50
|
-
|
|
51
47
|
if DurableStreams.server_url.present?
|
|
52
48
|
DurableStreams.configure do |c|
|
|
53
49
|
c.base_url = DurableStreams.server_url
|
|
54
|
-
c.default_headers = {
|
|
50
|
+
c.default_headers = {
|
|
51
|
+
"Authorization" => -> { "Bearer #{DurableStreams::Rails::Gatekeeper.encode({ "server" => "rails" })}" }
|
|
52
|
+
}
|
|
55
53
|
end
|
|
56
54
|
end
|
|
57
55
|
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require "jwt"
|
|
2
|
+
require "openssl"
|
|
3
|
+
|
|
4
|
+
# ES256 JWT signing for Durable Streams authentication.
|
|
5
|
+
#
|
|
6
|
+
# Replaces the symmetric HMAC approach (ActiveSupport::MessageVerifier) with
|
|
7
|
+
# asymmetric ES256 (ECDSA P-256). Rails holds the private key and signs tokens.
|
|
8
|
+
# Caddy verifies with the public key via ggicci/caddy-jwt — no callback to Rails.
|
|
9
|
+
#
|
|
10
|
+
# Descended from the deleted app-level +Gatekeeper+ model (commit e4f9c53),
|
|
11
|
+
# adapted for the gem.
|
|
12
|
+
#
|
|
13
|
+
# ==== Configuration
|
|
14
|
+
#
|
|
15
|
+
# DurableStreams.signing_key = Rails.application.credentials.dig(:durable_streams, :signing_key)
|
|
16
|
+
# DurableStreams.signing_kid = "es256-20260330"
|
|
17
|
+
# DurableStreams.token_issuer = "https://exchange.tokimonki.com"
|
|
18
|
+
# DurableStreams.token_expiry = 120 # seconds
|
|
19
|
+
#
|
|
20
|
+
# ==== Key generation
|
|
21
|
+
#
|
|
22
|
+
# rake durable_streams:generate_keys
|
|
23
|
+
#
|
|
24
|
+
module DurableStreams::Rails::Gatekeeper
|
|
25
|
+
ALG = "ES256"
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Encode a payload as an ES256 JWT.
|
|
29
|
+
#
|
|
30
|
+
# Standard claims (+iss+, +iat+, +exp+) are merged automatically.
|
|
31
|
+
# Pass +expires_in+ to override the default expiry.
|
|
32
|
+
#
|
|
33
|
+
# DurableStreams::Rails::Gatekeeper.encode("stream" => "rooms/1/messages", "permissions" => ["read"])
|
|
34
|
+
# DurableStreams::Rails::Gatekeeper.encode({ "server" => true }, expires_in: 120)
|
|
35
|
+
#
|
|
36
|
+
def encode(payload, expires_in: DurableStreams.token_expiry)
|
|
37
|
+
now = Time.now.to_i
|
|
38
|
+
|
|
39
|
+
claims = payload.merge(
|
|
40
|
+
"iss" => DurableStreams.token_issuer,
|
|
41
|
+
"aud" => DurableStreams.client_url,
|
|
42
|
+
"iat" => now,
|
|
43
|
+
"exp" => now + expires_in.to_i
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
headers = {}
|
|
47
|
+
headers["kid"] = DurableStreams.signing_kid if DurableStreams.signing_kid
|
|
48
|
+
|
|
49
|
+
::JWT.encode(claims, signing_key, ALG, headers)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Decode and verify a JWT. Used in testing — production verification happens in Caddy.
|
|
53
|
+
# The algorithm is hardcoded to ES256 to prevent algorithm confusion attacks.
|
|
54
|
+
# Issuer is verified to prevent cross-environment token reuse.
|
|
55
|
+
def decode(token)
|
|
56
|
+
options = {
|
|
57
|
+
algorithm: ALG,
|
|
58
|
+
verify_iss: true,
|
|
59
|
+
iss: DurableStreams.token_issuer
|
|
60
|
+
}
|
|
61
|
+
::JWT.decode(token, verify_key, true, options).first
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Generate an ES256 key pair. Returns [private_pem, public_pem].
|
|
65
|
+
#
|
|
66
|
+
# private_pem, public_pem = DurableStreams::Rails::Gatekeeper.generate_key_pair
|
|
67
|
+
#
|
|
68
|
+
def generate_key_pair
|
|
69
|
+
key = OpenSSL::PKey::EC.generate("prime256v1")
|
|
70
|
+
[ key.to_pem, key.public_to_pem ]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def reset_signing_key
|
|
74
|
+
@signing_key = nil
|
|
75
|
+
@verify_key = nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
def signing_key
|
|
80
|
+
@signing_key ||= OpenSSL::PKey.read(DurableStreams.signing_key)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def verify_key
|
|
84
|
+
@verify_key ||= OpenSSL::PKey.read(signing_key.public_to_pem)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -11,6 +11,8 @@ module DurableStreams
|
|
|
11
11
|
TEMPLATE = ERB.new(<<~'CADDYFILE', trim_mode: "-")
|
|
12
12
|
{
|
|
13
13
|
admin off
|
|
14
|
+
order jwtauth before basicauth
|
|
15
|
+
order durable_streams_authorisation after jwtauth
|
|
14
16
|
<%- unless auto_https? -%>
|
|
15
17
|
auto_https off
|
|
16
18
|
<%- end -%>
|
|
@@ -26,6 +28,10 @@ module DurableStreams
|
|
|
26
28
|
level <%= log_level %>
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
@options {
|
|
32
|
+
method OPTIONS
|
|
33
|
+
}
|
|
34
|
+
|
|
29
35
|
<%= route_block %>
|
|
30
36
|
}
|
|
31
37
|
<%- if internal -%>
|
|
@@ -66,10 +72,19 @@ module DurableStreams
|
|
|
66
72
|
|
|
67
73
|
private
|
|
68
74
|
def validate!
|
|
75
|
+
validate_auth!
|
|
69
76
|
validate_internal!
|
|
70
77
|
validate_log!
|
|
71
78
|
end
|
|
72
79
|
|
|
80
|
+
def validate_auth!
|
|
81
|
+
%w[verify_key issuer audience].each do |field|
|
|
82
|
+
if (val = auth[field]) && (val.include?("\n") || val.include?("}"))
|
|
83
|
+
raise ArgumentError, "auth.#{field} contains invalid characters for Caddyfile interpolation"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
73
88
|
def validate_internal!
|
|
74
89
|
if internal && !internal_allowed_ips
|
|
75
90
|
raise ArgumentError, "internal.allowed_ips is required when internal is configured"
|
|
@@ -118,28 +133,24 @@ module DurableStreams
|
|
|
118
133
|
t = "\t" * indent
|
|
119
134
|
lines = []
|
|
120
135
|
lines << "#{t}route #{route_path} {"
|
|
121
|
-
lines << "#{t}\
|
|
122
|
-
lines << "#{t}\
|
|
123
|
-
lines << "#{t}\t\
|
|
124
|
-
lines << "#{t}\t\
|
|
125
|
-
lines << "#{t}\t\
|
|
126
|
-
lines << "#{t}\t
|
|
127
|
-
lines << "#{t}\
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
lines << ""
|
|
135
|
-
lines << "#{t}\t\
|
|
136
|
-
lines << "#{t}\t\thandle_response @ok {"
|
|
137
|
-
copy_headers.each { |h| lines << "#{t}\t\t\trequest_header #{h} {rp.header.#{h}}" }
|
|
138
|
-
lines << "#{t}\t\t}"
|
|
139
|
-
lines << "#{t}\t\thandle_response {"
|
|
140
|
-
lines << "#{t}\t\t\trespond 401"
|
|
141
|
-
lines << "#{t}\t\t}"
|
|
136
|
+
lines << "#{t}\theader {"
|
|
137
|
+
lines << "#{t}\t\tAccess-Control-Allow-Origin *"
|
|
138
|
+
lines << "#{t}\t\tAccess-Control-Allow-Methods \"GET, POST, PUT, DELETE, HEAD, OPTIONS\""
|
|
139
|
+
lines << "#{t}\t\tAccess-Control-Allow-Headers \"Content-Type, Stream-Seq, Stream-TTL, Stream-Expires-At, Stream-Closed, If-None-Match, Producer-Id, Producer-Epoch, Producer-Seq\""
|
|
140
|
+
lines << "#{t}\t\tAccess-Control-Expose-Headers \"Stream-Next-Offset, Stream-Cursor, Stream-Up-To-Date, Stream-Closed, ETag, Location, Producer-Epoch, Producer-Seq, Producer-Expected-Seq, Producer-Received-Seq\""
|
|
141
|
+
lines << "#{t}\t}"
|
|
142
|
+
lines << "#{t}\trespond @options 204"
|
|
143
|
+
lines << "#{t}\tjwtauth {"
|
|
144
|
+
lines << "#{t}\t\tsign_alg ES256"
|
|
145
|
+
lines << "#{t}\t\tsign_key {file.#{auth["verify_key"]}}"
|
|
146
|
+
lines << "#{t}\t\tfrom_query token"
|
|
147
|
+
lines << "#{t}\t\tuser_claims stream server"
|
|
148
|
+
lines << "#{t}\t\tmeta_claims \"permissions -> permissions\" \"server -> server\" \"identity -> identity\""
|
|
149
|
+
lines << "#{t}\t\tissuer_whitelist #{auth["issuer"]}" if auth["issuer"]
|
|
150
|
+
lines << "#{t}\t\taudience_whitelist #{auth["audience"]}" if auth["audience"]
|
|
142
151
|
lines << "#{t}\t}"
|
|
152
|
+
lines << "#{t}\theader Referrer-Policy no-referrer"
|
|
153
|
+
lines << "#{t}\tdurable_streams_authorisation"
|
|
143
154
|
|
|
144
155
|
directives = storage_directives
|
|
145
156
|
if directives.any?
|
|
@@ -162,18 +173,6 @@ module DurableStreams
|
|
|
162
173
|
@config.fetch("auth")
|
|
163
174
|
end
|
|
164
175
|
|
|
165
|
-
def copy_headers
|
|
166
|
-
auth.fetch("copy_headers", [ "Cookie" ])
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def auth_host
|
|
170
|
-
auth["host"]
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def auth_tls_transport?
|
|
174
|
-
auth_host && auth["url"].start_with?("https://")
|
|
175
|
-
end
|
|
176
|
-
|
|
177
176
|
def storage_directives
|
|
178
177
|
storage = @config.fetch("storage", {})
|
|
179
178
|
STORAGE_DIRECTIVES.filter_map { |key| "#{key} #{storage[key]}" if storage[key] }
|
|
@@ -1,34 +1,13 @@
|
|
|
1
|
-
# Stream names
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
1
|
+
# Stream names identify which updates go to which subscribers. They are derived
|
|
2
|
+
# from Active Record models (via +to_gid_param+) or plain strings (via +to_param+),
|
|
3
|
+
# joined with +/+ for compound stream identifiers.
|
|
4
|
+
#
|
|
5
|
+
# DurableStreams.stream_name_from([:rooms, room, :messages])
|
|
6
|
+
# # => "rooms/gid://app/Room/1/messages"
|
|
5
7
|
#
|
|
6
|
-
# Tokens embed a permissions array (<tt>["read"]</tt>, <tt>["write"]</tt>, or
|
|
7
|
-
# <tt>["read", "write"]</tt>) that determines what operations the client can perform.
|
|
8
|
-
# The auth controller checks the permissions against the HTTP method of the incoming request.
|
|
9
8
|
module DurableStreams::Rails::StreamName
|
|
10
9
|
PERMISSIONS = %w[ read write ].freeze
|
|
11
10
|
|
|
12
|
-
# Verifies a signed token and returns the payload hash.
|
|
13
|
-
# Returns +nil+ if the token is invalid or expired.
|
|
14
|
-
#
|
|
15
|
-
# DurableStreams.verified_stream_name(token)
|
|
16
|
-
# # => { "stream" => "gid://app/Room/1/messages", "permissions" => ["read"] }
|
|
17
|
-
def verified_stream_name(signed_stream_name)
|
|
18
|
-
DurableStreams.signed_stream_verifier.verified signed_stream_name
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
# Generates a signed token embedding the stream name and permissions.
|
|
22
|
-
#
|
|
23
|
-
# Tokens generated without +expires_in+ never expire. Prefer
|
|
24
|
-
# <tt>DurableStreams.signed_stream_url</tt> for client-facing URLs.
|
|
25
|
-
def signed_stream_name(streamables, permissions: [ "read" ], expires_in: nil)
|
|
26
|
-
DurableStreams.signed_stream_verifier.generate(
|
|
27
|
-
{ "stream" => stream_name_from(streamables), "permissions" => permissions.map(&:to_s) },
|
|
28
|
-
expires_in: expires_in
|
|
29
|
-
)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
11
|
private
|
|
33
12
|
def stream_name_from(streamables)
|
|
34
13
|
if streamables.is_a?(Array)
|
|
@@ -37,4 +16,11 @@ module DurableStreams::Rails::StreamName
|
|
|
37
16
|
streamables.then { |streamable| streamable.try(:to_gid_param) || streamable.to_param }
|
|
38
17
|
end
|
|
39
18
|
end
|
|
19
|
+
|
|
20
|
+
def ensure_known_permissions!(permissions)
|
|
21
|
+
unknown = permissions.map(&:to_s) - PERMISSIONS
|
|
22
|
+
if unknown.any?
|
|
23
|
+
raise ArgumentError, "Unknown permissions: #{unknown.join(", ")}. Known: #{PERMISSIONS.join(", ")}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
40
26
|
end
|
|
@@ -4,6 +4,7 @@ require "durable_streams/rails/stream_name"
|
|
|
4
4
|
require "durable_streams/rails/stream_provisioner"
|
|
5
5
|
require "durable_streams/rails/broadcasts"
|
|
6
6
|
require "durable_streams/rails/feeds"
|
|
7
|
+
require "durable_streams/rails/gatekeeper"
|
|
7
8
|
require "durable_streams/rails/testing"
|
|
8
9
|
require "active_support/core_ext/module/attribute_accessors_per_thread"
|
|
9
10
|
|
|
@@ -16,27 +17,23 @@ module DurableStreams
|
|
|
16
17
|
|
|
17
18
|
mattr_accessor :client_url
|
|
18
19
|
mattr_accessor :server_url
|
|
19
|
-
mattr_accessor :
|
|
20
|
-
mattr_accessor :signed_stream_url_expires_in, default: 24.hours
|
|
20
|
+
mattr_accessor :signed_stream_url_expires_in, default: 15.minutes
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@signed_stream_verifier ||= ActiveSupport::MessageVerifier.new(signed_stream_verifier_key, digest: "SHA256", serializer: JSON)
|
|
27
|
-
end
|
|
22
|
+
# JWT signing configuration
|
|
23
|
+
mattr_accessor :signing_kid
|
|
24
|
+
mattr_accessor :token_issuer
|
|
25
|
+
mattr_accessor :token_expiry, default: 120
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
end
|
|
27
|
+
class << self
|
|
28
|
+
attr_writer :signing_key
|
|
32
29
|
|
|
33
|
-
def
|
|
34
|
-
@
|
|
30
|
+
def signing_key
|
|
31
|
+
@signing_key or raise ArgumentError, "DurableStreams requires a signing_key (ES256 private key PEM)"
|
|
35
32
|
end
|
|
36
33
|
|
|
37
34
|
# Generates a signed URL that grants a client read (or read/write) access to a
|
|
38
|
-
# Durable Stream identified by the given +streamables+. The URL embeds
|
|
39
|
-
#
|
|
35
|
+
# Durable Stream identified by the given +streamables+. The URL embeds an ES256
|
|
36
|
+
# JWT with the stream name, permissions, and expiry.
|
|
40
37
|
#
|
|
41
38
|
# Calls <tt>ensure_stream_exists</tt> to guarantee the stream has been created on
|
|
42
39
|
# the server (PUT, cached after the first call per stream per process — see
|
|
@@ -45,14 +42,36 @@ module DurableStreams
|
|
|
45
42
|
# DurableStreams.signed_stream_url(room, :messages)
|
|
46
43
|
# # => "http://localhost:4437/v1/streams/gid://app/Room/1/messages?token=..."
|
|
47
44
|
#
|
|
48
|
-
def signed_stream_url(*streamables, permissions: [ :read ], expires_in: DurableStreams.signed_stream_url_expires_in)
|
|
45
|
+
def signed_stream_url(*streamables, permissions: [ :read ], identity: nil, expires_in: DurableStreams.signed_stream_url_expires_in)
|
|
46
|
+
url, token = signed_stream_primitives(*streamables, permissions: permissions, identity: identity, expires_in: expires_in)
|
|
47
|
+
"#{url}?token=#{token}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns the stream URL and JWT token as separate values.
|
|
51
|
+
#
|
|
52
|
+
# The URL is the stable endpoint that never changes. The token is a short-lived
|
|
53
|
+
# ES256 JWT that must be refreshed before expiry. Returning them separately lets
|
|
54
|
+
# the frontend pass the token as a function-valued param to the Durable Streams
|
|
55
|
+
# client, enabling automatic token refresh on reconnect.
|
|
56
|
+
#
|
|
57
|
+
# url, token = DurableStreams.signed_stream_primitives(room, :messages)
|
|
58
|
+
# # url => "http://localhost:4437/v1/streams/gid://app/Room/1/messages"
|
|
59
|
+
# # token => "eyJ..."
|
|
60
|
+
#
|
|
61
|
+
def signed_stream_primitives(*streamables, permissions: [ :read ], identity: nil, expires_in: DurableStreams.signed_stream_url_expires_in)
|
|
62
|
+
ensure_known_permissions!(permissions)
|
|
63
|
+
|
|
49
64
|
path = stream_name_from(streamables)
|
|
50
65
|
ensure_stream_exists(path)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
66
|
+
|
|
67
|
+
claims = {
|
|
68
|
+
"stream" => path,
|
|
69
|
+
"permissions" => permissions.map(&:to_s),
|
|
70
|
+
"identity" => identity&.to_s
|
|
71
|
+
}.compact
|
|
72
|
+
|
|
73
|
+
token = DurableStreams::Rails::Gatekeeper.encode(claims, expires_in: expires_in)
|
|
74
|
+
[ "#{client_url}/#{path}", token ]
|
|
56
75
|
end
|
|
57
76
|
|
|
58
77
|
# Returns the current tail offset of the stream as an opaque string. The tail offset
|
|
@@ -79,22 +98,5 @@ module DurableStreams
|
|
|
79
98
|
ensure_stream_exists(path)
|
|
80
99
|
feed_head_offset(path)
|
|
81
100
|
end
|
|
82
|
-
|
|
83
|
-
# Verifies the signed token embedded in a stream URL. Used by the auth controller
|
|
84
|
-
# to validate forward_auth requests from the Durable Streams server.
|
|
85
|
-
#
|
|
86
|
-
# Returns a hash with <tt>"stream"</tt> and <tt>"permissions"</tt> keys, or +nil+
|
|
87
|
-
# if the token is invalid or expired.
|
|
88
|
-
def verify_signed_url(url)
|
|
89
|
-
if token = extract_token_from_url(url)
|
|
90
|
-
verified_stream_name(token)
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def extract_token_from_url(url)
|
|
95
|
-
if query = URI(url).query
|
|
96
|
-
Rack::Utils.parse_query(query)["token"]
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
101
|
end
|
|
100
102
|
end
|
|
@@ -11,10 +11,8 @@ default: &default
|
|
|
11
11
|
# format: console # "console" (human-readable) or "json" (structured). Default: console
|
|
12
12
|
# level: INFO # INFO, WARN, or ERROR. Default: INFO
|
|
13
13
|
auth:
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
copy_headers:
|
|
17
|
-
- Cookie
|
|
14
|
+
verify_key: config/caddy/verify_key.pem
|
|
15
|
+
issuer: https://localhost:3000
|
|
18
16
|
|
|
19
17
|
development:
|
|
20
18
|
<<: *default
|
|
@@ -37,11 +35,8 @@ production:
|
|
|
37
35
|
# format: json
|
|
38
36
|
# level: WARN
|
|
39
37
|
auth:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
copy_headers:
|
|
43
|
-
- Cookie
|
|
44
|
-
- Authorization
|
|
38
|
+
verify_key: /etc/durable_streams/verify_key.pem
|
|
39
|
+
issuer: https://app.example.com
|
|
45
40
|
storage:
|
|
46
41
|
data_dir: /var/data/durable-streams
|
|
47
42
|
# max_file_handles: 100 # Connection pool for segment files. Default: 100
|
|
@@ -5,3 +5,9 @@ DurableStreams.client_url = ENV.fetch("DURABLE_STREAMS_CLIENT_URL", "http://loca
|
|
|
5
5
|
# In single-server setups this matches client_url. In multi-server deployments
|
|
6
6
|
# this should point to the internal address of the Durable Streams server.
|
|
7
7
|
DurableStreams.server_url = ENV.fetch("DURABLE_STREAMS_SERVER_URL", "http://localhost:4437/v1/streams")
|
|
8
|
+
|
|
9
|
+
# ES256 JWT signing — private key signs tokens, Caddy verifies with public key.
|
|
10
|
+
# Generate a key pair: bin/rails durable_streams:generate_keys
|
|
11
|
+
Rails.application.config.durable_streams.signing_key = Rails.application.credentials.dig(:durable_streams, :signing_key)
|
|
12
|
+
Rails.application.config.durable_streams.signing_kid = Rails.application.credentials.dig(:durable_streams, :signing_kid)
|
|
13
|
+
Rails.application.config.durable_streams.token_issuer = "http://localhost:3000"
|
|
@@ -12,56 +12,27 @@ namespace :durable_streams do
|
|
|
12
12
|
puts "Generated #{output_path} (#{environment})"
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
desc "
|
|
16
|
-
task :
|
|
17
|
-
require "
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
os = RbConfig::CONFIG["host_os"].then { |s|
|
|
39
|
-
if s.match?(/darwin/i) then "darwin"
|
|
40
|
-
elsif s.match?(/linux/i) then "linux"
|
|
41
|
-
else abort "Unsupported OS: #{s}"
|
|
42
|
-
end
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
arch = RbConfig::CONFIG["host_cpu"].then { |s|
|
|
46
|
-
case s
|
|
47
|
-
when /x86_64|amd64/ then "amd64"
|
|
48
|
-
when /aarch64|arm64/ then "arm64"
|
|
49
|
-
else abort "Unsupported architecture: #{s}"
|
|
50
|
-
end
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if version == "latest"
|
|
54
|
-
version = resolve_latest_version.call
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
FileUtils.mkdir_p(install_dir)
|
|
58
|
-
|
|
59
|
-
url = "https://github.com/durable-streams/durable-streams/releases/download/v#{version}/durable-streams-server_#{version}_#{os}_#{arch}.tar.gz"
|
|
60
|
-
|
|
61
|
-
puts "Downloading durable-streams-server #{version} (#{os}/#{arch})..."
|
|
62
|
-
system("curl", "-sfL", url, "-o", "-", out: IO.popen(["tar", "xz", "-C", install_dir.to_s], "w").fileno) ||
|
|
63
|
-
abort("Download failed — check https://github.com/durable-streams/durable-streams/releases")
|
|
64
|
-
FileUtils.chmod(0755, bin_path)
|
|
65
|
-
puts "Installed to #{bin_path}"
|
|
15
|
+
desc "Generate ES256 key pair for JWT signing"
|
|
16
|
+
task :generate_keys do
|
|
17
|
+
require "durable_streams/rails/gatekeeper"
|
|
18
|
+
|
|
19
|
+
private_pem, public_pem = DurableStreams::Rails::Gatekeeper.generate_key_pair
|
|
20
|
+
|
|
21
|
+
puts "=== Private key ==="
|
|
22
|
+
puts "Add to Rails credentials (bin/rails credentials:edit):"
|
|
23
|
+
puts
|
|
24
|
+
puts "durable_streams:"
|
|
25
|
+
puts " signing_key: |"
|
|
26
|
+
private_pem.each_line { |line| puts " #{line}" }
|
|
27
|
+
puts " signing_kid: \"es256-#{Time.now.strftime("%Y%m%d")}\""
|
|
28
|
+
puts
|
|
29
|
+
puts "=== Public key ==="
|
|
30
|
+
puts "Save to config/caddy/verify_key.pem (safe to commit):"
|
|
31
|
+
puts
|
|
32
|
+
|
|
33
|
+
output_path = ::Rails.root.join("config", "caddy", "verify_key.pem")
|
|
34
|
+
FileUtils.mkdir_p(output_path.dirname)
|
|
35
|
+
File.write(output_path, public_pem)
|
|
36
|
+
puts "Written to #{output_path}"
|
|
66
37
|
end
|
|
67
38
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: durable_streams-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- tokimonki
|
|
@@ -23,6 +23,20 @@ dependencies:
|
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '0.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: jwt
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.0'
|
|
26
40
|
- !ruby/object:Gem::Dependency
|
|
27
41
|
name: railties
|
|
28
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -48,15 +62,14 @@ files:
|
|
|
48
62
|
- MIT-LICENSE
|
|
49
63
|
- README.md
|
|
50
64
|
- Rakefile
|
|
51
|
-
- app/controllers/durable_streams/rails/auth_controller.rb
|
|
52
65
|
- app/jobs/durable_streams/rails/broadcast_job.rb
|
|
53
66
|
- app/models/concerns/durable_streams/rails/broadcastable.rb
|
|
54
|
-
- config/routes.rb
|
|
55
67
|
- lib/durable_streams-rails.rb
|
|
56
68
|
- lib/durable_streams/rails/broadcastable/test_helper.rb
|
|
57
69
|
- lib/durable_streams/rails/broadcasts.rb
|
|
58
70
|
- lib/durable_streams/rails/engine.rb
|
|
59
71
|
- lib/durable_streams/rails/feeds.rb
|
|
72
|
+
- lib/durable_streams/rails/gatekeeper.rb
|
|
60
73
|
- lib/durable_streams/rails/server_config.rb
|
|
61
74
|
- lib/durable_streams/rails/stream_name.rb
|
|
62
75
|
- lib/durable_streams/rails/stream_provisioner.rb
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
module DurableStreams
|
|
2
|
-
module Rails
|
|
3
|
-
class AuthController < ActionController::API
|
|
4
|
-
def verify
|
|
5
|
-
if request.headers["X-Forwarded-Uri"] && authorized_by_token?
|
|
6
|
-
head :ok
|
|
7
|
-
elsif valid_server_api_key?
|
|
8
|
-
head :ok
|
|
9
|
-
else
|
|
10
|
-
head :unauthorized
|
|
11
|
-
end
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
private
|
|
15
|
-
def authorized_by_token?
|
|
16
|
-
if payload = DurableStreams.verify_signed_url(request.headers["X-Forwarded-Uri"])
|
|
17
|
-
permissions = Array(payload["permissions"]) & DurableStreams::Rails::StreamName::PERMISSIONS
|
|
18
|
-
permitted_method?(permissions)
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# Only GET and POST reach token auth. PUT and DELETE are server-only
|
|
23
|
-
# operations authenticated via API key (valid_server_api_key?), not tokens.
|
|
24
|
-
# See README.md "Access control" for the full matrix.
|
|
25
|
-
def permitted_method?(permissions)
|
|
26
|
-
case request.headers["X-Forwarded-Method"]&.upcase
|
|
27
|
-
when "POST"
|
|
28
|
-
permissions.include?("write")
|
|
29
|
-
else
|
|
30
|
-
permissions.include?("read")
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def valid_server_api_key?
|
|
35
|
-
if auth_header = request.headers["Authorization"]
|
|
36
|
-
auth_header.start_with?("Bearer ") &&
|
|
37
|
-
ActiveSupport::SecurityUtils.secure_compare(auth_header.delete_prefix("Bearer "), DurableStreams.server_api_key)
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
end
|
data/config/routes.rb
DELETED