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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3f4756bb48c61889c0f2ed552d9dfb96d37e15566a7b2c67d38ee3e48a3d2048
4
- data.tar.gz: 12c21fd8ff0842f3d121898fe6fe52da2914aa45b25dd1feb9752e232e40f3ca
3
+ metadata.gz: 29432e07eb91417e49d52901bc6edce8b6b7a706ed219c31c5b2880c40435e61
4
+ data.tar.gz: 0c7c63e0ffa19cad00031672cd9549270e28564d55925365ee0ff7dcaa6c4922
5
5
  SHA512:
6
- metadata.gz: be4dfd3ea33bfc0a1f641d05ce0a05a725e6d2f57a1f88da6145d4dbb2632b874128da7a4aeb682ad422ad1869bff3d984ae6707d6f68e05d52f5778fe770ff6
7
- data.tar.gz: 06a91cc06ff3b1d00314632e6f64e7db4ffbe505844ae31c12c75c644f142f703dbe833fec559279f4f6e05a286c4fbe2476121706320b7d55fc88920113e976
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
- ### Disable route auto-mounting
46
+ ## Configuration
47
+
48
+ ### JWT signing
47
49
 
48
- The gem auto-mounts the auth verification endpoint at `/durable_streams/auth/verify`.
49
- To disable:
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.draw_routes = false
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
- ## Configuration
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
- url: http://localhost:3000
66
- path: /durable_streams/auth/verify
67
- copy_headers:
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
- url: https://app.example.com
78
- path: /durable_streams/auth/verify
79
- copy_headers:
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 # Only listen on the private interface.
102
- allowed_ips: # Caddy remote_ip matcher — 403 for everyone else.
109
+ bind: 10.0.0.5
110
+ allowed_ips:
103
111
  - 10.0.0.4
104
112
  auth:
105
- url: https://10.0.0.4 # Connect to Rails over private VNET.
106
- host: app.example.com # Host header + TLS SNI for the Kamal proxy.
107
- path: /durable_streams/auth/verify
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
- reverse_proxy app.example.com (validates Bearer token) - this is "forward_auth" expanded
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 API key; clients authenticate via signed, expiring tokens generated by this gem.
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 still performs forward_auth to Rails on every write to verify
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), forward_auth happens once at connection time.
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
- # Paired with server_api_key for authentication.
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 generated by `MessageVerifier` and encodes the stream name, permissions
437
- (`read`/`write`), and an expiration timestamp.
437
+ The token is an ES256 JWT encoding the stream name, permissions (`read`/`write`), issuer,
438
+ and an expiration timestamp.
438
439
 
439
- When a client connects, the Durable Streams server's `forward_auth` sends the request to
440
- `AuthController#verify`, which validates the token's signature and expiration.
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 — API key
447
+ ### Server authentication — ES256 JWT Bearer tokens
447
448
 
448
- Rails authenticates to the Durable Streams server with a Bearer token (`server_api_key`)
449
- sent in the `Authorization` header. The key is auto-derived from
450
- `Rails.application.key_generator` consistent across all instances of the same app
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.configs" do
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
- DurableStreams.signed_stream_verifier_key =
40
- config.durable_streams.signed_stream_verifier_key ||
41
- ::Rails.application.key_generator.generate_key("durable_streams/signed_stream_verifier_key")
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
- initializer "durable_streams_rails.server_api_key" do
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 = { "Authorization" => "Bearer #{DurableStreams.server_api_key}" }
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}\t# Forward auth — verify request with Rails before reaching durable_streams"
122
- lines << "#{t}\treverse_proxy #{auth["url"]} {"
123
- lines << "#{t}\t\tmethod GET"
124
- lines << "#{t}\t\trewrite #{auth["path"]}"
125
- lines << "#{t}\t\theader_up Host #{auth_host}" if auth_host
126
- lines << "#{t}\t\theader_up X-Forwarded-Method {method}"
127
- lines << "#{t}\t\theader_up X-Forwarded-Uri {uri}"
128
- if auth_tls_transport?
129
- lines << "#{t}\t\ttransport http {"
130
- lines << "#{t}\t\t\ttls_server_name #{auth_host}"
131
- lines << "#{t}\t\t\ttls_insecure_skip_verify"
132
- lines << "#{t}\t\t}"
133
- end
134
- lines << ""
135
- lines << "#{t}\t\t@ok status 200"
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 are how we identify which updates should go to which subscribers. Since stream names
2
- # are exposed directly to the client via signed URL tokens, we need to ensure that the name isn't
3
- # tampered with, so the names are signed upon generation and verified upon receipt. All verification
4
- # happens through the <tt>DurableStreams.signed_stream_verifier</tt>.
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
@@ -1,5 +1,5 @@
1
1
  module DurableStreams
2
2
  module Rails
3
- VERSION = "0.7.0"
3
+ VERSION = "0.8.0"
4
4
  end
5
5
  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 :draw_routes, default: true
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
- class << self
23
- attr_writer :signed_stream_verifier_key, :server_api_key
24
-
25
- def signed_stream_verifier
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
- def signed_stream_verifier_key
30
- @signed_stream_verifier_key or raise ArgumentError, "DurableStreams requires a signed_stream_verifier_key"
31
- end
27
+ class << self
28
+ attr_writer :signing_key
32
29
 
33
- def server_api_key
34
- @server_api_key or raise ArgumentError, "DurableStreams requires a server_api_key"
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 a signed
39
- # token with the stream name, permissions, and expiry.
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
- token = signed_stream_verifier.generate(
52
- { "stream" => path, "permissions" => permissions.map(&:to_s) },
53
- expires_in: expires_in
54
- )
55
- "#{client_url}/#{path}?token=#{token}"
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
- url: http://localhost:3000
15
- path: /durable_streams/auth/verify
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
- url: https://app.example.com
41
- path: /durable_streams/auth/verify
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 "Download the Durable Streams server binary for the current platform"
16
- task :download do
17
- require "net/http"
18
- require "json"
19
-
20
- resolve_latest_version = lambda do
21
- uri = URI("https://api.github.com/repos/durable-streams/durable-streams/releases/latest")
22
- response = Net::HTTP.get(uri)
23
- tag = JSON.parse(response).fetch("tag_name")
24
- tag.delete_prefix("v")
25
- rescue => e
26
- abort "Failed to resolve latest version: #{e.message}"
27
- end
28
-
29
- version = ENV.fetch("DURABLE_STREAMS_VERSION", "latest")
30
- install_dir = Rails.root.join("bin", "dist")
31
- bin_path = install_dir.join("durable-streams-server")
32
-
33
- if bin_path.exist?
34
- puts "Durable Streams server already installed at #{bin_path}"
35
- next
36
- end
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.7.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
@@ -1,3 +0,0 @@
1
- ::Rails.application.routes.draw do
2
- get "durable_streams/auth/verify", to: "durable_streams/rails/auth#verify", as: :durable_streams_auth_verify
3
- end if DurableStreams.draw_routes