vatio-sdk 0.6.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a3ca193521e55a9d1c5c6918c5f7100f632effa64168fa4ccd8bc1ba27918357
4
+ data.tar.gz: 71f39cb25a704a68b4cd47d0cd45fb7ca7a0162a879a1c281b4a47d86f2d911f
5
+ SHA512:
6
+ metadata.gz: 8755693383c6f26e929c9dc0be96de067fdbaeed3d2d5dff227b9eed9fe034cfab244d17fa0f8b5baa0464c9849efffcb8b390039c672b4aeafdf87d6129f004
7
+ data.tar.gz: a5f349288b444d346dcb2d951e7a5305aef998f2564120306b40ea39c8a5b74dc9bd123e9e897de36df7dcc8517db30c05c196fa9fe7ee113915c0c4a6d5d0e6
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Urcalab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,423 @@
1
+ # vatio-sdk
2
+
3
+ Signs and verifies the one credential a Vatio workspace authenticates its users
4
+ with: a JWT your application signed with its own private key.
5
+
6
+ Vatio holds only the public half, so it can check a token and never mint one.
7
+ That is the property that makes it reasonable for a platform to be verifying
8
+ identity at all — and it is also why everything dangerous about the arrangement
9
+ sits on *your* side of the line. This gem is that side, done once.
10
+
11
+ ```ruby
12
+ gem "vatio-sdk", "~> 0.6"
13
+ ```
14
+
15
+ On [RubyGems](https://rubygems.org/gems/vatio-sdk) from 0.6.0. Until then it was
16
+ called `vatio-identity` and installed from git; a Gemfile that still says
17
+ `gem "vatio-identity", git: "https://github.com/vatio-ai/ruby"` keeps working
18
+ and installs this, so switch when you next touch it. The code, the requires and the `Vatio::Identity` and
19
+ `Vatio::Server` modules did not change.
20
+
21
+ ## Setup
22
+
23
+ The keypair comes from the CLI, not from here — `vatio auth --new-key` writes
24
+ `identity.pub` and `identity.pem` in the workspace, gitignores the private half
25
+ and prints the `vatio.yml` to go with it. There is deliberately no rake task
26
+ that does the same thing differently.
27
+
28
+ ```ruby
29
+ # config/initializers/vatio.rb
30
+ Vatio::Identity.configure do |c|
31
+ c.audience = "acme" # your workspace slug
32
+ c.private_key = ENV.fetch("VATIO_IDENTITY_PRIVATE_KEY")
33
+ end
34
+ ```
35
+
36
+ `algorithm` is inferred from the key — the curve fixes it for EC, RS256 for RSA
37
+ — and has to match `auth.algorithm` in `vatio.yml`.
38
+
39
+ ## The web
40
+
41
+ ```erb
42
+ <%= raw Vatio::Identity.widget_tag(
43
+ workspace: "acme",
44
+ token: "vatpub_...",
45
+ subject: current_user&.id,
46
+ claims: { name: current_user&.name, email: current_user&.email }) %>
47
+ ```
48
+
49
+ No `subject`, no visitor token, and the agent answers with its public tools —
50
+ which is what a signed-out visitor should get.
51
+
52
+ Add `metadata` to `claims` and whoever answers in the inbox sees a link to the
53
+ person in your admin, plus badges:
54
+
55
+ ```ruby
56
+ claims: {
57
+ name: current_user.name,
58
+ metadata: {
59
+ profile: admin_user_url(current_user),
60
+ badges: { plan: { value: current_user.plan, tone: "green" }, country: current_user.country }
61
+ }
62
+ }
63
+ ```
64
+
65
+ See [Enrich the inbox](https://vatio.ai/docs/authentication/#enrich-the-inbox).
66
+
67
+ **Watch the cache.** Rails renders this per request, so it is safe by default.
68
+ The moment it lands in a page cache, a CDN or `caches_action`, you are serving
69
+ one customer's identity to the next visitor. If anything caches the page,
70
+ render it without a token and call `window.VatioWidget.identify(token)` from an
71
+ uncached endpoint instead.
72
+
73
+ ## WhatsApp and Instagram
74
+
75
+ A WhatsApp or Instagram chat has no page to put a token on, so when a private
76
+ tool needs one Vatio sends the contact a "Sign in" button. It opens
77
+ `auth.sign_in_url` from `vatio.yml` with a `code`; sign them in the way your
78
+ site already does, then send them back:
79
+
80
+ ```ruby
81
+ # auth:
82
+ # public_key: identity.pub
83
+ # sign_in_url: https://acme.com/vatio/sign-in
84
+ class VatioSignInsController < ApplicationController
85
+ before_action :authenticate_user! # your own login
86
+
87
+ def show
88
+ # Back from a Turbo login form: fetch cannot follow a redirect to vatio.ai.
89
+ if request.headers["X-Turbo-Request-Id"]
90
+ return render html: helpers.tag.meta(name: "turbo-visit-control", content: "reload")
91
+ end
92
+
93
+ redirect_to Vatio::Identity.sign_in_redirect_url(
94
+ code: params[:code],
95
+ subject: current_user.id,
96
+ claims: { name: current_user.name, phone_number: current_user.phone }
97
+ ), allow_other_host: true
98
+ end
99
+ end
100
+ ```
101
+
102
+ The token lasts a week by default (`expires_in:` to change it); when it runs
103
+ out the button goes out again. A `phone_number` claim that matches the WhatsApp
104
+ number skips the "is this yours?" confirmation; on Instagram it is always asked.
105
+
106
+ Keep the Turbo lines if your login form uses Turbo: after signing in, the form's
107
+ `fetch` follows the redirect back here and cannot follow the next one to
108
+ vatio.ai, so the login page would just sit there. The reload makes it a real
109
+ page load.
110
+
111
+ ## Your own API, behind a private tool
112
+
113
+ ```ruby
114
+ class Api::BookingsController < ApplicationController
115
+ include Vatio::Identity::Authentication
116
+ before_action :authenticate_vatio!
117
+
118
+ def index
119
+ render json: Booking.where(user_id: vatio_subject)
120
+ end
121
+ end
122
+ ```
123
+
124
+ Scope by `vatio_subject` and nothing else. Vatio already refuses to deploy a
125
+ private tool that takes `user_id` as a parameter — the model fills parameters
126
+ and can be talked into filling that one with someone else's id — and the same
127
+ reasoning applies one layer down: an endpoint that accepts an id has to
128
+ remember to scope it every time, forever; one that reads the token cannot
129
+ forget.
130
+
131
+ ## Several workspaces
132
+
133
+ `configure` sets one workspace for the whole app. An app that serves several —
134
+ each customer with their own Vatio workspace and key — builds a `Config` per
135
+ workspace instead and passes it as `config:`:
136
+
137
+ ```ruby
138
+ config = Vatio::Identity::Config.new(audience: tenant.vatio_slug, public_key: tenant.vatio_public_key)
139
+
140
+ Vatio::Identity.verify(token, config: config)
141
+ ```
142
+
143
+ `token_for`, `widget_tag` and `sign_in_redirect_url` take it too; those sign,
144
+ so that config needs `private_key:`. Behind a private tool, the controller
145
+ says which workspace the request is for, and `nil` for a tenant that has not
146
+ connected Vatio answers 401:
147
+
148
+ ```ruby
149
+ class Api::BookingsController < ApplicationController
150
+ include Vatio::Identity::Authentication
151
+ before_action :authenticate_vatio!
152
+
153
+ private
154
+
155
+ def vatio_identity_config
156
+ return if current_tenant.vatio_public_key.blank?
157
+
158
+ Vatio::Identity::Config.new(audience: current_tenant.vatio_slug, public_key: current_tenant.vatio_public_key)
159
+ end
160
+ end
161
+ ```
162
+
163
+ Pick the tenant from the request — the host, the path — and never from the
164
+ token's own `aud`: the token is what is being checked.
165
+
166
+ A server client for another workspace takes its identity config the same way,
167
+ so a `subject:` is signed with that workspace's key:
168
+
169
+ ```ruby
170
+ Vatio::Server::Client.new(server_key: tenant.vatio_server_key, workspace: tenant.vatio_slug,
171
+ identity: Vatio::Identity::Config.new(audience: tenant.vatio_slug, private_key: tenant.vatio_private_key))
172
+ ```
173
+
174
+ `SendMessageJob` always uses the global configuration.
175
+
176
+ ## Server API (messages)
177
+
178
+ Everything above runs without a network call. This part is the opposite: a
179
+ client for the server API, which has the agent write to someone first on
180
+ WhatsApp — an order that shipped, an appointment tomorrow, a quote that is
181
+ ready — and the receiver for the webhooks that say how it went. It is a
182
+ separate require, so an app that only signs tokens never loads it:
183
+
184
+ ```ruby
185
+ # config/initializers/vatio.rb
186
+ require "vatio/server"
187
+
188
+ Vatio::Server.configure do |c|
189
+ c.server_key = ENV.fetch("VATIO_SERVER_KEY") # vsk_..., from `vatio keys create`
190
+ c.workspace = "acme" # defaults to Vatio::Identity's audience
191
+ c.webhook_secret = ENV["VATIO_WEBHOOK_SECRET"] # whsec_..., for the receiver below
192
+ end
193
+ ```
194
+
195
+ A server key belongs to one workspace and one environment, and a preview key
196
+ can only write to a test phone verified under Test → WhatsApp in the console. Keep
197
+ it on your server: it can message your customers. Net::HTTP does the talking
198
+ (`base_url`, `open_timeout` and `read_timeout` are there if you need them);
199
+ there is no other dependency.
200
+
201
+ ### Sending
202
+
203
+ ```ruby
204
+ message = Vatio::Server.send_message(
205
+ to: "+56912345678",
206
+ brief: "Order #1042 shipped today with Starken, tracking 99812. Offer to send the tracking link.",
207
+ external_ref: "order-1042-shipped"
208
+ )
209
+ message.queued? # => true -- recorded and on its way
210
+ message.id # => 81
211
+ ```
212
+
213
+ What goes out depends on WhatsApp's 24-hour window, which Vatio checks for
214
+ you:
215
+
216
+ - **`brief:`** — the agent writes the message, and on every later turn it is
217
+ shown why it wrote, so "yes, send it" gets an answer that knows what "it"
218
+ is. Write it the way you would brief a colleague: the agent uses no facts
219
+ beyond the brief and its tools.
220
+ - **`text:`** — your own words, sent as is.
221
+ - **`template:`** — `{ name:, language:, params: [] }`, used only when the
222
+ window is closed, because then WhatsApp delivers nothing else. Send it
223
+ along with the brief to always reach the person. `Vatio::Server.templates`
224
+ lists what the number can send, with each body's parameter count:
225
+
226
+ ```ruby
227
+ shipped = Vatio::Server.templates.find { |t| t.name == "order_shipped" }
228
+ Vatio::Server.send_message(to: phone, brief: brief, template: shipped.with_params("Ana", "#1042"))
229
+ ```
230
+
231
+ The response only says it is queued: the agent's turn and Meta's delivery
232
+ both happen after it. `Vatio::Server.message_status(id)` reads it back,
233
+ with `content` (what reached the phone) once it is sent, and
234
+ `Vatio::Server.messages(external_ref:, status:, limit:, before:)` lists
235
+ them, newest first — or, better, the webhook tells you.
236
+
237
+ **Who it is.** If the person is one of your users, pass `subject:` (and
238
+ `claims:`, as for the widget) and the message carries an identity signed
239
+ with your key, so a private tool can run when they answer:
240
+
241
+ ```ruby
242
+ Vatio::Server.send_message(to: user.phone, brief: brief, external_ref: "invoice-#{invoice.id}-due",
243
+ subject: user.id, claims: { name: user.name })
244
+ ```
245
+
246
+ The token's `exp` is `identity_ttl` from now, 48 hours by default, and it has
247
+ to cover how long the person may take to answer: past it, they are answered
248
+ as a stranger. Pass a longer one for a message people sit on. Or sign it
249
+ yourself and pass `identity:`.
250
+
251
+ ### Idempotency
252
+
253
+ A request that timed out may still have gone through, so a retry has to be
254
+ safe. It is when it carries an idempotency key: the same key with the same
255
+ request answers with the message the first one made (`replayed?` is true)
256
+ and sends nothing. `idempotency_key:` defaults to `external_ref:`, so give
257
+ each *message* its own ref — `"order-1042-shipped"`, not `"order-1042"` —
258
+ or pass a key explicitly. The same key with a different request is a
259
+ `Conflict`, not a second message.
260
+
261
+ If you retry a call with `subject:` yourself, sign once with
262
+ `Vatio::Server.identity_for(subject:, claims:)` and pass that as `identity:`:
263
+ a token signed again is a different request to that check.
264
+
265
+ ### Errors
266
+
267
+ Every refusal raises a `Vatio::Server::Error` with `code`, `message`,
268
+ `status`, `details` and `request_id` (quote it when you ask us about one).
269
+ The subclasses are the decisions you actually make:
270
+
271
+ ```ruby
272
+ begin
273
+ Vatio::Server.send_message(to: phone, brief: brief, external_ref: ref)
274
+ rescue Vatio::Server::OutsideWindow => e
275
+ # Has not written in 24 hours and no template was sent. Only you know
276
+ # whether a template is the right thing instead. e.last_inbound_at
277
+ rescue Vatio::Server::OptedOut, Vatio::Server::HumanAnswering
278
+ # They asked not to be written to, or a person has the conversation
279
+ # (human_takeover, handoff_pending). Leave it.
280
+ rescue Vatio::Server::RateLimited, Vatio::Server::Unavailable => e
281
+ # e.retryable? is true for these two only. Retry with the same key.
282
+ end
283
+ ```
284
+
285
+ | Class | When |
286
+ |---|---|
287
+ | `OutsideWindow` | `outside_window`, with `last_inbound_at` |
288
+ | `OptedOut` | `opted_out` |
289
+ | `HumanAnswering` | `human_takeover`, `handoff_pending` |
290
+ | `TemplateError` | `template_not_found` (often created in Meta a minute ago: Vatio is syncing, retry shortly), `template_not_approved`, `template_params`, `template_required` |
291
+ | `InvalidRequest` | Any other 422: `invalid_recipient`, `content_required`, `invalid_identity`, `not_a_test_phone`… |
292
+ | `Unauthorized` | 401 / 403: the key is wrong, revoked, or another workspace's |
293
+ | `Conflict` | Any other 409: `idempotency_conflict` (`details["message_id"]`), `identity_conflict`, `whatsapp_not_connected`, `whatsapp_paused` |
294
+ | `NotFound` | `message_status` with an id this key cannot see |
295
+ | `RateLimited` | 429, more than 60 a minute on one key |
296
+ | `Unavailable` | 5xx, timeouts, refused connections |
297
+
298
+ A missing `server_key` or `workspace` is a `Vatio::Server::ConfigurationError`,
299
+ not one of these: it is your bug, not Vatio's answer.
300
+
301
+ ### ActiveJob
302
+
303
+ Where ActiveJob is loaded, `Vatio::Server::SendMessageJob` takes the same
304
+ keywords:
305
+
306
+ ```ruby
307
+ Vatio::Server::SendMessageJob.perform_later(to: order.phone, brief: brief,
308
+ external_ref: "order-#{order.id}-shipped", subject: order.user_id)
309
+ ```
310
+
311
+ It retries `RateLimited` and `Unavailable` with backoff, ten attempts over
312
+ about four hours, and never the rest — a closed window is the same answer
313
+ on the tenth try — which it logs and discards. To act on one, subclass and
314
+ `discard_on` it; the later declaration wins:
315
+
316
+ ```ruby
317
+ class ShippedJob < Vatio::Server::SendMessageJob
318
+ discard_on(Vatio::Server::OutsideWindow) { |job, error| ShippedMailer.notify(job.arguments.first[:to]).deliver_later }
319
+ end
320
+ ```
321
+
322
+ Pass an `external_ref` or `idempotency_key` so a retry is a replay rather than
323
+ a second message; without one the job makes a key when it is enqueued. An
324
+ identity for `subject:` is signed at enqueue as well, once, for the same
325
+ reason — which means it waits in your queue, and its `exp` counts from then.
326
+
327
+ ### Webhooks
328
+
329
+ Vatio POSTs `message.sent`, `message.failed`, `message.delivered`,
330
+ `message.read`, `message.delivery_failed` and `message.replied` to your
331
+ endpoint, signed with its `whsec_` secret. `data["message"]` is the message
332
+ as `message_status` returns it, and `message.replied` adds `data["reply"]`
333
+ (`chat_message_id`, `content`, `at`):
334
+
335
+ ```ruby
336
+ # config/routes.rb
337
+ post "/webhooks/vatio", to: "vatio_webhooks#create"
338
+
339
+ class VatioWebhooksController < ActionController::API
340
+ include Vatio::Server::WebhookReceiver
341
+
342
+ on_vatio_event "message.replied" do |event|
343
+ next if ProcessedEvent.exists?(event_id: event["id"])
344
+
345
+ message = Vatio::Server::Message.from(event["data"]["message"])
346
+ Order.find_by(ref: message.external_ref)&.update!(customer_replied_at: message.replied_at)
347
+ ProcessedEvent.create!(event_id: event["id"])
348
+ end
349
+ end
350
+ ```
351
+
352
+ The receiver checks `Vatio-Signature` against the raw body and refuses a
353
+ delivery more than five minutes old, so a captured one cannot be replayed;
354
+ it answers 400 to anything that does not verify and 200 otherwise. Define
355
+ `vatio_webhook_secret` in the controller to read the secret from elsewhere,
356
+ or override `handle_vatio_event(event)` to see every event. Outside Rails,
357
+ `Vatio::Server::Webhook.verify!(payload:, signature:, secret:)` returns the
358
+ parsed event or raises `Webhook::InvalidSignature`.
359
+
360
+ **Delivery is at least once.** A delivery your server took too long to
361
+ answer is sent again, with the same `event["id"]`: dedupe on it. A handler
362
+ that raises answers 500 and the delivery is retried later, which is what you
363
+ want when your database was down — and why a handler should be safe to run
364
+ twice.
365
+
366
+ ### Testing
367
+
368
+ ```ruby
369
+ require "vatio/server/testing"
370
+
371
+ setup { Vatio::Server::Testing.fake! }
372
+ teardown { Vatio::Server::Testing.real! }
373
+
374
+ test "shipping tells the customer" do
375
+ Order.ship!(order)
376
+ sent = Vatio::Server::Testing.messages.last
377
+ assert_equal "order-#{order.id}-shipped", sent.external_ref
378
+ assert_equal order.user_id.to_s, sent.subject
379
+ end
380
+
381
+ test "a closed window falls back to email" do
382
+ Vatio::Server::Testing.fail_next!(:outside_window)
383
+ assert_emails(1) { Order.ship!(order) }
384
+ end
385
+ ```
386
+
387
+ `fake!` swaps the HTTP transport for an in-memory one — no network, no key
388
+ needed — that answers as the API does: a queued message, a replay for a key
389
+ it has seen, `Conflict` for one reused with a different request. The answers
390
+ go through the same parsing and error mapping as real ones, so what your code
391
+ rescues in a test is what it rescues in production. `fail_next!` takes any
392
+ refusal code (or `:timeout`); `window_open = false` makes a message without
393
+ a template raise `OutsideWindow`; `templates =` sets what `templates`
394
+ returns; `webhook("message.replied", message: {...})` builds a signed
395
+ request for testing your receiver. With RSpec loaded:
396
+
397
+ ```ruby
398
+ expect { Order.ship!(order) }.to have_sent_message(to: order.phone, brief: a_string_including("#1042"))
399
+ ```
400
+
401
+ See [Messages](https://vatio.ai/docs/api/messages) for the API itself.
402
+
403
+ ## What it does not do
404
+
405
+ Rotate keys, or cache tokens. Verification is repeated on every gate by
406
+ design, so there is no conclusion worth caching: a token cached as "valid"
407
+ has its `exp` checked exactly once.
408
+
409
+ `vatio/identity` never talks to Vatio: signing and verifying are local, and
410
+ nothing about the widget or your private tools makes a request from here.
411
+ `vatio/server` is the one part that does, only when you require it and call
412
+ it, and it does not retry on its own — a retry belongs to whoever knows
413
+ whether the message still makes sense, which is why `SendMessageJob` is where
414
+ the retries are.
415
+
416
+ ## Issues
417
+
418
+ Bugs and questions go to [issues](https://github.com/vatio-ai/ruby/issues).
419
+ This repository is a read-only mirror of the gem as it ships inside Vatio, so
420
+ a pull request cannot be merged here: open an issue describing the change
421
+ instead.
422
+
423
+ MIT licensed.
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vatio
4
+ module Identity
5
+ # For the other end: your own API, behind a private tool.
6
+ #
7
+ # class Api::BookingsController < ApplicationController
8
+ # include Vatio::Identity::Authentication
9
+ # before_action :authenticate_vatio!
10
+ #
11
+ # def index
12
+ # render json: Booking.where(user_id: vatio_subject)
13
+ # end
14
+ # end
15
+ #
16
+ # Scope by `vatio_subject` and nothing else. Vatio already refuses to
17
+ # deploy a private tool that takes `user_id` as a parameter -- the model
18
+ # fills parameters and can be talked into filling that one with somebody
19
+ # else's id -- and the same reasoning applies one layer down: an endpoint
20
+ # that accepts an id has to remember to scope it every time, forever, and
21
+ # one that reads the token cannot forget.
22
+ #
23
+ # An app serving several workspaces says which one the request is for,
24
+ # and nil for one that has not connected Vatio, which is a 401:
25
+ #
26
+ # def vatio_identity_config
27
+ # current_tenant.vatio_identity_config
28
+ # end
29
+ module Authentication
30
+ def self.included(base)
31
+ base.helper_method(:vatio_subject, :vatio_principal) if base.respond_to?(:helper_method)
32
+ end
33
+
34
+ def vatio_principal
35
+ return @vatio_principal if defined?(@vatio_principal)
36
+
37
+ config = vatio_identity_config
38
+ @vatio_principal = config && Identity.verify(vatio_bearer_token, config: config)
39
+ end
40
+
41
+ def vatio_subject
42
+ vatio_principal&.subject
43
+ end
44
+
45
+ def vatio_signed_in?
46
+ !vatio_principal.nil?
47
+ end
48
+
49
+ def authenticate_vatio!
50
+ return if vatio_signed_in?
51
+
52
+ render json: { error: "unauthorized" }, status: :unauthorized
53
+ end
54
+
55
+ private
56
+
57
+ def vatio_identity_config
58
+ Identity.config
59
+ end
60
+
61
+ def vatio_bearer_token
62
+ header = request.headers["Authorization"].to_s
63
+ header[/\ABearer (.+)\z/i, 1]
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Vatio
6
+ module Identity
7
+ # Everything the signer needs, and nothing that could be guessed wrong.
8
+ #
9
+ # `audience` is the workspace slug, always. Vatio requires `aud` and checks
10
+ # it against the slug, so a token signed for the wrong audience is not a
11
+ # subtly weaker token -- it is rejected outright and the visitor comes
12
+ # through anonymous.
13
+ #
14
+ # `Vatio::Identity.configure` fills the one most apps need. An app that
15
+ # serves several workspaces builds one per workspace and passes it as
16
+ # `config:`:
17
+ #
18
+ # Vatio::Identity::Config.new(audience: tenant.slug, public_key: tenant.vatio_public_key)
19
+ class Config
20
+ # An EC key names its own algorithm: the curve fixes the hash size, so
21
+ # there is exactly one right answer and asking for it would only be a
22
+ # chance to get it wrong. RSA does not -- RS* and PS* share a key -- so
23
+ # RS256 is the default. Mirrors the table the manifest validates against.
24
+ CURVE_ALGORITHMS = {
25
+ "prime256v1" => "ES256",
26
+ "secp256r1" => "ES256",
27
+ "secp384r1" => "ES384",
28
+ "secp521r1" => "ES512"
29
+ }.freeze
30
+ RSA_DEFAULT = "RS256"
31
+
32
+ # Long enough that a visitor is not signed out mid-conversation, short
33
+ # enough that a token scraped from a page stops working the same morning.
34
+ # The web refreshes it on every page load anyway.
35
+ DEFAULT_EXPIRES_IN = 3600
36
+
37
+ attr_accessor :audience
38
+ attr_writer :algorithm, :expires_in
39
+
40
+ def initialize(audience: nil, private_key: nil, public_key: nil, algorithm: nil,
41
+ expires_in: DEFAULT_EXPIRES_IN)
42
+ @audience = audience
43
+ @algorithm = algorithm
44
+ @expires_in = expires_in
45
+ self.private_key = private_key
46
+ self.public_key = public_key
47
+ end
48
+
49
+ # The PEM, not a path. Read it from an env var or Rails credentials --
50
+ # a private key checked into the repo is a private key that leaked.
51
+ def private_key=(pem)
52
+ @private_key = load_key(pem, "private_key")
53
+ return if @private_key.nil? || @private_key.private?
54
+
55
+ raise ConfigurationError,
56
+ "vatio identity: private_key is a PUBLIC key -- it cannot sign. " \
57
+ "Point it at identity.pem, not identity.pub"
58
+ end
59
+
60
+ # Optional. Only needed to verify tokens coming back into your own API
61
+ # from a private tool; if unset, the private key verifies its own
62
+ # signatures, which is the same check.
63
+ def public_key=(pem)
64
+ @public_key = load_key(pem, "public_key")
65
+ end
66
+
67
+ def private_key
68
+ @private_key || raise(ConfigurationError, missing("private_key"))
69
+ end
70
+
71
+ def public_key
72
+ @public_key || @private_key || raise(ConfigurationError, missing("public_key"))
73
+ end
74
+
75
+ def expires_in = Integer(@expires_in)
76
+
77
+ def audience!
78
+ value = audience.to_s.strip
79
+ return value unless value.empty?
80
+
81
+ raise ConfigurationError, missing("audience (your Vatio workspace slug)")
82
+ end
83
+
84
+ # Inferred from the key unless pinned. Whatever it ends up being has to
85
+ # equal `auth.algorithm` in vatio.yml, and it is never read from the
86
+ # token's own header on either side: that header is attacker-controlled,
87
+ # and believing it is exactly the algorithm-confusion bug.
88
+ def algorithm
89
+ return @algorithm.to_s if @algorithm
90
+
91
+ key = @private_key || @public_key
92
+ raise ConfigurationError, missing("private_key") if key.nil?
93
+
94
+ case key
95
+ when OpenSSL::PKey::EC
96
+ curve = key.group.curve_name.to_s
97
+ CURVE_ALGORITHMS.fetch(curve) do
98
+ raise ConfigurationError,
99
+ "vatio identity: curve #{curve.inspect} has no matching JWT algorithm; set config.algorithm"
100
+ end
101
+ when OpenSSL::PKey::RSA then RSA_DEFAULT
102
+ else
103
+ raise ConfigurationError,
104
+ "vatio identity: #{key.class} keys cannot sign a Vatio token; use RSA or EC"
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def load_key(pem, name)
111
+ value = pem.to_s.strip
112
+ return nil if value.empty?
113
+
114
+ OpenSSL::PKey.read(value)
115
+ rescue OpenSSL::PKey::PKeyError, ArgumentError, TypeError => e
116
+ raise ConfigurationError, "vatio identity: #{name} is not a readable PEM (#{e.class})"
117
+ end
118
+
119
+ def missing(name)
120
+ "vatio identity: #{name} is not configured -- see Vatio::Identity.configure"
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Vatio
6
+ module Identity
7
+ class Railtie < ::Rails::Railtie
8
+ initializer "vatio.identity.authentication" do
9
+ ActiveSupport.on_load(:action_controller) do
10
+ require_relative "authentication"
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vatio
4
+ module Identity
5
+ VERSION = "0.6.0"
6
+ end
7
+ end