patchwork-rb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +348 -0
- data/lib/patchwork/bridge_assertion.rb +64 -0
- data/lib/patchwork/configuration.rb +63 -0
- data/lib/patchwork/errors.rb +9 -0
- data/lib/patchwork/gateway.rb +180 -0
- data/lib/patchwork/health_check.rb +36 -0
- data/lib/patchwork/mint.rb +38 -0
- data/lib/patchwork/presented.rb +53 -0
- data/lib/patchwork/rails.rb +70 -0
- data/lib/patchwork/secure_compare.rb +13 -0
- data/lib/patchwork/session_token.rb +65 -0
- data/lib/patchwork/signature.rb +105 -0
- data/lib/patchwork/signing_key.rb +69 -0
- data/lib/patchwork/subject.rb +162 -0
- data/lib/patchwork/version.rb +3 -0
- data/lib/patchwork/webhook.rb +77 -0
- data/lib/patchwork-rb.rb +1 -0
- data/lib/patchwork.rb +41 -0
- metadata +109 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e8b84f16b49af905d391e3f3d47e67339f1fec7cf28f6ee7d002d72b4d4c20aa
|
|
4
|
+
data.tar.gz: 9a0d2be627ba90e61da19d3512c1cf0e2520bf132cf11446199f029092958a9d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 61db91122e933dfbbe9a96370cd8a2b7db3529b4ffe62f07ba263a7911596cea23cbbc6902dac2a22ad250aca11c4ff185913c756a83dfb04d1d509c65472a52
|
|
7
|
+
data.tar.gz: e002d213489d59a7df24aa98aa0f45e5c41b0fbd61e7cffb2323911f04a750566f23c7d5e9c250dd2c030d3401a5c378655ddd10cef44dd06a5d73e276ed5885
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chromablue Labs
|
|
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,348 @@
|
|
|
1
|
+
# patchwork-rb
|
|
2
|
+
|
|
3
|
+
The Ruby SDK for [Patchwork](https://usepatchwork.co). It mints session tokens for your users, verifies the tool calls Patchwork makes to your backend, and verifies webhook deliveries.
|
|
4
|
+
|
|
5
|
+
**Scope, so you know what you are installing.** This version is the *inbound* half: everything Patchwork sends you, and the credentials you hand it. It is the part that is genuinely unpleasant to write by hand — the signature covers the raw body, a rotation puts two `v1=` values in one header, comparisons must be constant-time, and the token allowlist must never accept a symmetric algorithm. A client for *calling* the Patchwork API (`identify`, `extract`, threads, runs) lands in 0.2; until then those are ordinary HTTP calls with a bearer token.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Gemfile
|
|
9
|
+
gem "patchwork-rb"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The gem is `patchwork-rb`, and you `require "patchwork"`. Don't add `gem "patchwork"`: that name belongs to an unrelated gem on RubyGems.
|
|
13
|
+
|
|
14
|
+
Everything here is plain JWT and HMAC that you could write by hand. The gem exists so the security-sensitive parts are right by default: constant-time comparison, secret rotation, bounded clock skew, and an algorithm allowlist that can never accept a symmetric algorithm.
|
|
15
|
+
|
|
16
|
+
It holds no opinion about who your users are. The subject you mint for is an opaque string of your choosing. The gem only interprets it when you ask it to, in a format you declare.
|
|
17
|
+
|
|
18
|
+
## 1. Configure
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# config/initializers/patchwork.rb
|
|
22
|
+
Patchwork.configure do |config|
|
|
23
|
+
config.signing_key = ENV.fetch("PATCHWORK_SIGNING_KEY") # RSA private key, PEM or base64 PEM
|
|
24
|
+
config.issuer = ENV.fetch("PATCHWORK_API_KEY_ID") # your API key's public id, key_…
|
|
25
|
+
config.audience = "acme-api" # your own API's audience
|
|
26
|
+
config.request_secret = ENV.fetch("PATCHWORK_REQUEST_SECRET") # the connection's shared secret
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
| Setting | Required for | Notes |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| `signing_key` | minting, tool calls | Never leaves your server. Patchwork only ever holds the public half. |
|
|
33
|
+
| `issuer` | minting, tool calls | Routes a token to your workspace. It identifies a key and authorises nothing. |
|
|
34
|
+
| `audience` | minting, tool calls | No default. Tokens carry `["patchwork", audience]`, and tool calls are verified against it. |
|
|
35
|
+
| `request_secret` | tool calls, health check | Set `previous_request_secret` during a rotation. |
|
|
36
|
+
| `signing_kid` | optional | Defaults to the key's RFC 7638 thumbprint, so a new key gets a new `kid` automatically. |
|
|
37
|
+
| `token_ttl` | optional | Seconds. Defaults to 120. The browser remints as needed. |
|
|
38
|
+
| `max_token_lifetime` | optional | Seconds. Defaults to 900. Verification refuses a token whose own `exp - iat` is longer, so a good signature over an absurd expiry is still rejected. `nil` turns the check off. |
|
|
39
|
+
|
|
40
|
+
A missing setting raises `Patchwork::ConfigurationError` the first time it is needed. So does a blank secret. An empty string is a valid HMAC key, so accepting one would make every signature forgeable.
|
|
41
|
+
|
|
42
|
+
## 2. Choose your subject
|
|
43
|
+
|
|
44
|
+
A subject is the partition key for a user's threads and memory. Patchwork stores it byte for byte and never parses it. Pick the smallest unit that must never see another unit's data:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
"usr_9f2" one account per person
|
|
48
|
+
"usr_9f2:ws_acme" a person inside one of their workspaces
|
|
49
|
+
"slack:T04AB:U0APP" a Slack user, namespaced by team
|
|
50
|
+
"ticket:8821" a conversation, not a person
|
|
51
|
+
"ws_acme" a whole team, sharing one memory on purpose
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The **Subjects** guide in the Patchwork docs covers how to choose. The gem supports every shape.
|
|
55
|
+
|
|
56
|
+
**Single-value subjects** need nothing from the gem. Pass the string.
|
|
57
|
+
|
|
58
|
+
**Composite subjects** are where mistakes happen: a flipped order, or an id that contains the delimiter. You can declare the format once instead:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
# config/initializers/patchwork.rb, next to Patchwork.configure
|
|
62
|
+
WorkspaceSubject = Patchwork::Subject.define(:user_id, :workspace_id)
|
|
63
|
+
|
|
64
|
+
WorkspaceSubject.encode(user_id: user.id, workspace_id: workspace.id) # => "usr_9f2:ws_acme"
|
|
65
|
+
WorkspaceSubject.decode("usr_9f2:ws_acme").workspace_id # => "ws_acme"
|
|
66
|
+
WorkspaceSubject.decode("usr_9f2") # => nil
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Define as many parts as your boundary needs, with an optional namespace prefix and your own delimiter:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
Patchwork::Subject.define(:ticket_id, prefix: "ticket") # "ticket:8821"
|
|
73
|
+
Patchwork::Subject.define(:team_id, :user_id, prefix: "slack") # "slack:T04AB:U0APP"
|
|
74
|
+
Patchwork::Subject.define(:org_id, :user_id, :project_id, delimiter: "|") # "o1|u2|p3"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A format enforces the composition rules so a call site can't get them wrong:
|
|
78
|
+
|
|
79
|
+
- **Order comes from the definition.** `encode` is keyword-only, so the order at the call site doesn't matter.
|
|
80
|
+
- **A part containing the delimiter is refused** at encode time. Otherwise it would be split wrongly on decode.
|
|
81
|
+
- **Empty or whitespace-padded parts are refused.** `"usr_9f2 "` would silently be a different subject, with an empty history.
|
|
82
|
+
- **`decode` never guesses.** The prefix and the part count must match exactly, or it returns `nil`. `decode!` raises `Patchwork::UnknownSubject` instead.
|
|
83
|
+
|
|
84
|
+
Every subject, composed or not, must be a non-empty, valid UTF-8 String with no leading or trailing whitespace (Unicode spaces included) and no control characters. `SessionToken.issue` checks this before it signs anything.
|
|
85
|
+
|
|
86
|
+
Formats have two more rules:
|
|
87
|
+
- **Part names** must be lowercase identifiers, and can't be the name of a Struct method (`hash`, `freeze`, `to_h` and so on).
|
|
88
|
+
- **Delimiters** can't contain whitespace. With a delimiter longer than one character, a part that would make the string ambiguous (`"acme:"` with `"::"`) is refused, so each subject string decodes to exactly one set of parts.
|
|
89
|
+
|
|
90
|
+
If you decode several formats that share a delimiter, try the prefixed ones first: `define(:a, :b)` will also match `"slack:x"`.
|
|
91
|
+
|
|
92
|
+
## 3. Mint tokens for your users
|
|
93
|
+
|
|
94
|
+
Your frontend asks your backend for a token, and your backend decides who the user is. Patchwork never sees how you authenticate.
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
class PatchworkTokensController < ApplicationController
|
|
98
|
+
before_action :authenticate_user!
|
|
99
|
+
|
|
100
|
+
def create
|
|
101
|
+
# Every component comes from the server session, never from request params.
|
|
102
|
+
subject = WorkspaceSubject.encode(user_id: current_user.id, workspace_id: current_workspace.id)
|
|
103
|
+
|
|
104
|
+
render json: {
|
|
105
|
+
token: Patchwork::SessionToken.issue(subject: subject),
|
|
106
|
+
expires_in: Patchwork.config.token_ttl
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Pass `connection_id:` when the agent has unpinned customer tools. It takes a connection UUID, not a name.
|
|
113
|
+
|
|
114
|
+
### Patchwork minting for you (relay)
|
|
115
|
+
|
|
116
|
+
Patchwork also calls your mint endpoint, server to server, when it needs a token for a subject it already acts for. That covers relay connections and **every Playground run**. It sends a signed `POST` with `{"subject": "..."}` and expects a bare `{ token, expires_in }` back. Both callers share your one `mint_url`, and the signature is what tells them apart.
|
|
117
|
+
|
|
118
|
+
Give the gateway your mint path and it answers Patchwork's call itself. Your frontend's unsigned request to the same path still reaches your controller above:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
Rails.application.config.middleware.use Patchwork::Gateway,
|
|
122
|
+
mint_path: "/patchwork/mint",
|
|
123
|
+
resolve: ...
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Without the gateway, verify and mint in your own route:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
def create
|
|
130
|
+
if request.headers["Patchwork-Signature"].present?
|
|
131
|
+
return render json: Patchwork::Mint.relay(body: request.raw_post,
|
|
132
|
+
signature: request.headers["Patchwork-Signature"],
|
|
133
|
+
path: request.path), status: :created
|
|
134
|
+
end
|
|
135
|
+
# ...the direct branch above
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Either way the token is minted for exactly the subject Patchwork sent, never a default. An unusable subject is a 400.
|
|
140
|
+
|
|
141
|
+
### Publish your key
|
|
142
|
+
|
|
143
|
+
Publish your public key so Patchwork can verify what you signed, and set the URL on your workspace:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
get "/patchwork/jwks", to: ->(_env) {
|
|
147
|
+
[ 200, { "content-type" => "application/json" }, [ Patchwork::SigningKey.jwks.to_json ] ]
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## 4. Verify the tool calls Patchwork makes to you
|
|
152
|
+
|
|
153
|
+
When an agent calls one of your tools, Patchwork sends the request with a `Patchwork-Signature` header and the session token you minted. `Patchwork::Gateway` is Rack middleware that verifies both. It then hands the token's subject to a `resolve` callable, which you write, to decide what the request acts as.
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
# config/initializers/patchwork.rb
|
|
157
|
+
Rails.application.config.middleware.use Patchwork::Gateway,
|
|
158
|
+
subject: WorkspaceSubject,
|
|
159
|
+
resolve: ->(ref) {
|
|
160
|
+
# Your own records, not the token's say-so.
|
|
161
|
+
Membership.find_by(user_id: ref.user_id, workspace_id: ref.workspace_id)
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
In Sinatra or bare Rack it's the same call: `use Patchwork::Gateway, subject: ..., resolve: ...`.
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
class Api::Tools::OrdersController < ApplicationController
|
|
169
|
+
def index
|
|
170
|
+
membership = request.env["patchwork.principal"] # whatever resolve returned
|
|
171
|
+
render json: membership.workspace.orders.recent
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**`resolve` is required, and it is where authorisation happens.** A valid token proves you minted it for that subject. It does not prove the pairing is still valid: the user may have left the workspace since. Look the subject up in your own records and return whatever the request should act as. Return `nil` or `false` to refuse. An empty collection or an empty string also refuses, so an accidental `where(...)` that matches nothing can't authorise a request. Return a record, not a relation.
|
|
177
|
+
|
|
178
|
+
### In a controller instead of middleware
|
|
179
|
+
|
|
180
|
+
Middleware is the right default: it covers every tool route at once. When you would rather verify per controller, the concern runs the same code in a `before_action`:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
require "patchwork/rails"
|
|
184
|
+
|
|
185
|
+
class Api::Tools::OrdersController < ApplicationController
|
|
186
|
+
include Patchwork::Rails::Presented
|
|
187
|
+
patchwork_subject_format WorkspaceSubject
|
|
188
|
+
|
|
189
|
+
def index
|
|
190
|
+
render json: patchwork_principal.workspace.orders.recent
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def patchwork_resolve(ref)
|
|
196
|
+
Membership.find_by(user_id: ref.user_id, workspace_id: ref.workspace_id)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`patchwork_resolve` is the same contract as `resolve` above, and defining it is mandatory — a controller that forgets raises rather than quietly skipping authorisation. `patchwork_subject_format` is optional and matches `subject:`. Verification renders a 401 itself, so an action body only ever runs for a verified call, and `patchwork_subject`, `patchwork_claims` and `patchwork_principal` are available inside it.
|
|
202
|
+
|
|
203
|
+
Unlike the middleware, a request with no `Patchwork-Signature` is a 401 here rather than a fall-through: you mounted the concern on this controller, so nothing else was expected to arrive.
|
|
204
|
+
|
|
205
|
+
This is the only part of the gem that needs `activesupport`, and it loads only when you require `patchwork/rails`.
|
|
206
|
+
|
|
207
|
+
**`subject:` is optional.** With a format, the gateway decodes first and passes `resolve` the decoded parts. A subject in any other shape is a 401, and your code never sees it. Without a format, `resolve` gets the raw string, and you can interpret it however you like:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
use Patchwork::Gateway, resolve: ->(subject, claims) {
|
|
211
|
+
case subject
|
|
212
|
+
when /\Aticket:(\d+)\z/ then Ticket.find_by(id: $1)
|
|
213
|
+
when /\Ajob:/ then :system
|
|
214
|
+
end
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`resolve` can take `(subject)` or `(subject, claims)`. Any object that responds to `call` works.
|
|
219
|
+
|
|
220
|
+
After a successful call, the request env carries:
|
|
221
|
+
|
|
222
|
+
| Key | Value |
|
|
223
|
+
| --- | --- |
|
|
224
|
+
| `patchwork.principal` | what `resolve` returned |
|
|
225
|
+
| `patchwork.subject` | the raw `sub` string |
|
|
226
|
+
| `patchwork.claims` | the verified token claims |
|
|
227
|
+
|
|
228
|
+
Failure behaviour:
|
|
229
|
+
|
|
230
|
+
- **Requests without a `Patchwork-Signature`** pass straight through to your own auth, untouched. The gateway doesn't read their bodies.
|
|
231
|
+
- **Anything that fails after the signature is present** returns a 401 with a JSON error and never reaches your app. This covers a bad or stale signature, a missing, forged, expired or wrong-audience token, a subject in the wrong format, and a `resolve` that refuses.
|
|
232
|
+
- **A missing `request_secret`** raises instead of rejecting quietly, so a broken deploy shows up as errors rather than as silently locked-out tools.
|
|
233
|
+
- **A signed body over `max_body_bytes`** (1 MiB by default) is a 413, and the gateway reads no more than the limit. A malformed or stale signature header is refused before the body is read at all.
|
|
234
|
+
- **401 bodies are generic** (`invalid signature`, `invalid token`, `subject not authorized`), so they don't reveal your audience or issuer.
|
|
235
|
+
- **A signed request with no bearer token** is a 401, unless it is the relay mint above. Patchwork sends one only for the mint and for a Patch dry-run started without a session token.
|
|
236
|
+
|
|
237
|
+
### What the signature does and doesn't cover
|
|
238
|
+
|
|
239
|
+
The signature covers the method, the path and the raw body. **It does not cover the query string**, and Patchwork sends a GET tool's arguments as query parameters. So a GET tool's arguments are not integrity-protected. Anyone who can alter a request in flight (a TLS-terminating proxy, for example) can change them without breaking the signature. Give any tool whose arguments matter, such as ids, amounts or anything that writes, a `POST` binding, where the arguments travel in the signed body.
|
|
240
|
+
|
|
241
|
+
The signed path is the one your app sees. If a proxy rewrites paths before your app does, mount the gateway where the original path is still intact.
|
|
242
|
+
|
|
243
|
+
**Method overrides are refused.** A signed request that carries `X-HTTP-Method-Override`, or that `Rack::MethodOverride` has already rewritten, is a 401. Patchwork never sends one, and the header isn't signed.
|
|
244
|
+
|
|
245
|
+
### Replays
|
|
246
|
+
|
|
247
|
+
A signature is valid for 300 seconds, and a captured request can be replayed inside that window. For tools with side effects, pass a `replay_guard`. It is called with a key and a TTL, and must return truthy only the first time it sees that key:
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
Rails.application.config.middleware.use Patchwork::Gateway,
|
|
251
|
+
resolve: ...,
|
|
252
|
+
replay_guard: ->(key, ttl) { Rails.cache.write(key, true, unless_exist: true, expires_in: ttl) }
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Use a cache that every app process shares. A per-process cache only stops replays that land on the same process.
|
|
256
|
+
|
|
257
|
+
## 5. Health check
|
|
258
|
+
|
|
259
|
+
The gateway answers `POST /patchwork/up` (also when it's mounted under a prefix) with a proof that only the shared secret can produce. That's how **Test** on a connection in the Patchwork console reports **verified** rather than just reachable. Pass `health_check: false` to handle that route yourself with `Patchwork::HealthCheck.respond(body:)`.
|
|
260
|
+
|
|
261
|
+
## 6. Verify webhooks
|
|
262
|
+
|
|
263
|
+
Webhooks use the same signing scheme, keyed with the **webhook endpoint's own secret**, not the request secret.
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
class PatchworkWebhooksController < ActionController::API
|
|
267
|
+
def create
|
|
268
|
+
event = Patchwork::Webhook.verify!(
|
|
269
|
+
body: request.raw_post,
|
|
270
|
+
signature: request.headers["Patchwork-Signature"],
|
|
271
|
+
secret: ENV.fetch("PATCHWORK_WEBHOOK_SECRET"),
|
|
272
|
+
path: "/hooks/patchwork"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
case event.type
|
|
276
|
+
when "run.completed" then FulfilJob.perform_later(event.run_id)
|
|
277
|
+
when "run.failed" then alert(event.data)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
head :ok
|
|
281
|
+
rescue Patchwork::InvalidSignature
|
|
282
|
+
head :unauthorized
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
- **Read the raw body.** `request.raw_post` is the signed bytes. Re-serialised params are not.
|
|
288
|
+
- **Pass the path you registered.** A full URL works too. Use the registered path even if a proxy rewrites it.
|
|
289
|
+
- **Rotate with `secrets: [new, old]`** instead of `secret:`.
|
|
290
|
+
- **Deduplicate on `event.id`.** A delivery can arrive more than once.
|
|
291
|
+
|
|
292
|
+
`verify!` raises `Patchwork::InvalidSignature`, or its subclass `Patchwork::StaleSignature`. `verify` returns `nil` instead.
|
|
293
|
+
|
|
294
|
+
## 7. Rotating secrets and keys
|
|
295
|
+
|
|
296
|
+
**Request secret:** set the new value as `request_secret` and the old one as `previous_request_secret`. Deploy, update the connection in Patchwork, then remove the previous value.
|
|
297
|
+
|
|
298
|
+
**Signing key:** serve both public keys from your JWKS, and switch `signing_key` once Patchwork has fetched the new set. Never remove the old key first. With `signing_kid` unset, each key's `kid` is its thumbprint, so the two can't collide. To serve more than one key, build the document yourself from `Patchwork::SigningKey.public_jwk` plus the previous one.
|
|
299
|
+
|
|
300
|
+
## MCP login bridge
|
|
301
|
+
|
|
302
|
+
If you export an agent over MCP, Patchwork sends your user to a login bridge you host, and the bridge hands back a signed subject — the one step only you can do. It is signed with the same key you mint with:
|
|
303
|
+
|
|
304
|
+
```ruby
|
|
305
|
+
get "/patchwork/bridge" do
|
|
306
|
+
subject = WorkspaceSubject.encode(user_id: current_user.id, workspace_id: chosen_workspace.id)
|
|
307
|
+
assertion = Patchwork::BridgeAssertion.issue(
|
|
308
|
+
subject: subject,
|
|
309
|
+
nonce: params[:state],
|
|
310
|
+
audience: PATCHWORK_CALLBACK
|
|
311
|
+
)
|
|
312
|
+
redirect "#{PATCHWORK_CALLBACK}?state=#{params[:state]}&assertion=#{assertion}"
|
|
313
|
+
end
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Authenticate the user first, and bake the workspace choice into the subject — that decision is yours, and Patchwork never needs to know your tenants. The assertion is bound to the `state` nonce and the callback audience and lives 120 seconds at most, which is the ceiling the callback enforces; asking for longer raises.
|
|
317
|
+
|
|
318
|
+
## Errors
|
|
319
|
+
|
|
320
|
+
| Error | Means |
|
|
321
|
+
| --- | --- |
|
|
322
|
+
| `Patchwork::ConfigurationError` | A required setting or secret is missing or blank, or the signing key is not an unencrypted RSA private key of at least 2048 bits. Treat it as a deploy bug. |
|
|
323
|
+
| `Patchwork::InvalidSignature` | The HMAC did not match any configured secret. |
|
|
324
|
+
| `Patchwork::StaleSignature` | The signature timestamp is outside the 300-second window. It subclasses `InvalidSignature`. |
|
|
325
|
+
| `Patchwork::InvalidToken` | The token was forged, expired, for another audience or issuer, or had no usable `sub`. |
|
|
326
|
+
| `Patchwork::UnknownSubject` | A subject didn't match its declared format, or `resolve` refused it. |
|
|
327
|
+
| `Patchwork::LifetimeExceeded` | The token's own lifetime is longer than `max_token_lifetime`. It subclasses `InvalidToken`. |
|
|
328
|
+
| `ArgumentError` | A subject you tried to mint or encode breaks the rules in section 2. |
|
|
329
|
+
|
|
330
|
+
All of them except `ArgumentError` inherit from `Patchwork::Error`. None of them carries key material in its message.
|
|
331
|
+
|
|
332
|
+
## Lower level
|
|
333
|
+
|
|
334
|
+
The primitives are public if you need to sign or verify outside Rack:
|
|
335
|
+
|
|
336
|
+
```ruby
|
|
337
|
+
Patchwork::Signature.header(secrets: [secret], timestamp: Time.now.to_i, method: "POST", path: "/v1/runs", body: body)
|
|
338
|
+
Patchwork::Signature.verify!(secrets: [current, previous], header: header, method: "POST", path: path, body: raw_body)
|
|
339
|
+
Patchwork::SessionToken.verify(token) # => claims, or raises Patchwork::InvalidToken
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
## Compatibility
|
|
343
|
+
|
|
344
|
+
Ruby 3.1+. One runtime dependency: `jwt` (>= 2.10.3, < 4, excluding 3.0.0–3.1.2, which are affected by GHSA-c32j-vqhx-rx3x). `Patchwork::Gateway` also needs `rack`, and is only defined when Rack is loaded. It works with any Rack app: Rails, Sinatra, Hanami or bare Rack. `Patchwork::Rails::Presented` needs `activesupport` and loads only when you require `patchwork/rails`.
|
|
345
|
+
|
|
346
|
+
## Licence
|
|
347
|
+
|
|
348
|
+
MIT.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
require "jwt"
|
|
2
|
+
|
|
3
|
+
module Patchwork
|
|
4
|
+
module BridgeAssertion
|
|
5
|
+
ALLOWED_ALGORITHMS = %w[RS256 RS384 RS512 ES256 ES384 ES512 PS256 PS384 PS512].freeze
|
|
6
|
+
MAX_TTL = 120
|
|
7
|
+
LEEWAY = 30
|
|
8
|
+
|
|
9
|
+
def self.issue(subject:, nonce:, audience:, ttl: MAX_TTL)
|
|
10
|
+
Subject.validate!(subject)
|
|
11
|
+
state = nonce.to_s
|
|
12
|
+
raise ArgumentError, "nonce is required" if state.empty?
|
|
13
|
+
raise ArgumentError, "audience is required" if audience.to_s.empty?
|
|
14
|
+
raise ArgumentError, "ttl must be #{MAX_TTL}s or less" if ttl > MAX_TTL
|
|
15
|
+
|
|
16
|
+
now = Time.now.to_i
|
|
17
|
+
claims = {
|
|
18
|
+
sub: subject,
|
|
19
|
+
aud: audience.to_s,
|
|
20
|
+
nonce: state,
|
|
21
|
+
iat: now,
|
|
22
|
+
exp: now + ttl
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
JWT.encode(claims, SigningKey.private_key, SigningKey::ALG, { kid: SigningKey.kid })
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.verify(token, audience:, nonce:, key: nil)
|
|
29
|
+
expected = nonce.to_s
|
|
30
|
+
raise ArgumentError, "nonce is required" if expected.empty?
|
|
31
|
+
|
|
32
|
+
claims = decode(token, audience, key)
|
|
33
|
+
raise InvalidToken, "nonce mismatch" unless SecureCompare.call(claims["nonce"], expected)
|
|
34
|
+
|
|
35
|
+
validate_subject!(claims["sub"])
|
|
36
|
+
claims
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.validate_subject!(subject)
|
|
40
|
+
Subject.validate!(subject)
|
|
41
|
+
rescue ArgumentError => e
|
|
42
|
+
raise InvalidToken, e.message
|
|
43
|
+
end
|
|
44
|
+
private_class_method :validate_subject!
|
|
45
|
+
|
|
46
|
+
def self.decode(token, audience, key)
|
|
47
|
+
JWT.decode(
|
|
48
|
+
token.to_s,
|
|
49
|
+
key || SigningKey.public_key,
|
|
50
|
+
true,
|
|
51
|
+
algorithms: ALLOWED_ALGORITHMS,
|
|
52
|
+
aud: audience.to_s,
|
|
53
|
+
verify_aud: true,
|
|
54
|
+
verify_expiration: true,
|
|
55
|
+
exp_leeway: LEEWAY,
|
|
56
|
+
nbf_leeway: LEEWAY,
|
|
57
|
+
required_claims: %w[sub aud exp nonce]
|
|
58
|
+
).first
|
|
59
|
+
rescue JWT::DecodeError => e
|
|
60
|
+
raise InvalidToken, e.message
|
|
61
|
+
end
|
|
62
|
+
private_class_method :decode
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module Patchwork
|
|
2
|
+
class Configuration
|
|
3
|
+
# signing_key — your RSA private key, PEM or base64-encoded PEM.
|
|
4
|
+
# signing_kid — the JWKS key id. Defaults to the key's RFC 7638 thumbprint,
|
|
5
|
+
# so rotating the key rotates the kid with it.
|
|
6
|
+
# issuer — your Patchwork API key's public id (key_…).
|
|
7
|
+
# audience — your own API's audience. Minted tokens carry it next to
|
|
8
|
+
# "patchwork", and tool calls are verified against it.
|
|
9
|
+
# request_secret / previous_request_secret — the connection's shared secret,
|
|
10
|
+
# and the one it replaced while a rotation is in flight.
|
|
11
|
+
attr_accessor :signing_key, :signing_kid, :issuer, :audience,
|
|
12
|
+
:request_secret, :previous_request_secret, :token_ttl,
|
|
13
|
+
:max_token_lifetime
|
|
14
|
+
|
|
15
|
+
DEFAULT_TTL = 120
|
|
16
|
+
DEFAULT_MAX_TOKEN_LIFETIME = 900
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@token_ttl = DEFAULT_TTL
|
|
20
|
+
@max_token_lifetime = DEFAULT_MAX_TOKEN_LIFETIME
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def request_secrets
|
|
24
|
+
[ request_secret, previous_request_secret ].map(&:to_s).reject(&:empty?)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def signing_key?
|
|
28
|
+
!signing_key.to_s.empty?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def request_secret?
|
|
32
|
+
!request_secrets.empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def issuer!
|
|
36
|
+
fetch!(:issuer)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def audience!
|
|
40
|
+
fetch!(:audience)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
SECRETS = %i[signing_key request_secret previous_request_secret].freeze
|
|
44
|
+
|
|
45
|
+
# Default #inspect prints every ivar, so a config that reaches a log line,
|
|
46
|
+
# an error tracker or a console would carry the private key with it.
|
|
47
|
+
def inspect
|
|
48
|
+
fields = %i[issuer audience signing_kid token_ttl max_token_lifetime].map { |name| "#{name}=#{public_send(name).inspect}" }
|
|
49
|
+
fields += SECRETS.map { |name| "#{name}=#{public_send(name).to_s.empty? ? 'nil' : '[REDACTED]'}" }
|
|
50
|
+
"#<Patchwork::Configuration #{fields.join(', ')}>"
|
|
51
|
+
end
|
|
52
|
+
alias to_s inspect
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def fetch!(name)
|
|
57
|
+
value = public_send(name).to_s
|
|
58
|
+
raise ConfigurationError, "#{name} is not set — see Patchwork.configure" if value.empty?
|
|
59
|
+
|
|
60
|
+
value
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module Patchwork
|
|
2
|
+
Error = Class.new(StandardError)
|
|
3
|
+
ConfigurationError = Class.new(Error)
|
|
4
|
+
InvalidToken = Class.new(Error)
|
|
5
|
+
LifetimeExceeded = Class.new(InvalidToken)
|
|
6
|
+
InvalidSignature = Class.new(Error)
|
|
7
|
+
StaleSignature = Class.new(InvalidSignature)
|
|
8
|
+
UnknownSubject = Class.new(Error)
|
|
9
|
+
end
|