masks 0.11.0 → 0.12.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: e082a74bac3486cd6f3291ef29a91dd0ecc325a2f3938465a44722c66e2363b0
4
- data.tar.gz: 22b5932157ba69c6c8401ab71b54ab0be1c640ae0f5da67f0ffa04c1fa1d15fa
3
+ metadata.gz: 22811283549c975e2550ab8546760831275db1efa68ddfd16ebe1dcfb96278a5
4
+ data.tar.gz: 888decf6ea332f751cb446a4e7535313527141ed64086cbf62acc2787d320a9b
5
5
  SHA512:
6
- metadata.gz: ed991af661f602ac3e32c190575bcb8f03f1f4ad5dce0d4006516ad2587e53efaa15c75b51a480b9ccbb1634f88844cc3a32ceb4c516313f93b928d01298d71f
7
- data.tar.gz: ef37a84b598962d464d24c68e33db41645ab6e85be67b34d13bae1061f061db0ce2a0180d1b4e82ec77c98a17bfcdbee4812fd2a855783ae2868211e8ff54741
6
+ metadata.gz: 33275896c9010d94818885f7a0a3f71b83f6782af7384cf29fb70d482b03492b8e662f27d6f748e1d9a05f453f5294dc910ca5d1492395c7bc3e0bc57aaaea6f
7
+ data.tar.gz: 5fb2c1d80a01a0723c3533bc8861cca4c2d0761e9ef5d4ef21f46de41d611835bf9fefe28ac0bab860b65bb0ee166cf8288fbda1a178644f58dc3a850edaea96
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.0](https://github.com/masksrb/masks/compare/gem/v0.11.0...gem/v0.12.0) (2026-10-14)
4
+
5
+
6
+ ### Features
7
+
8
+ * **client:** config.cache shares refreshes, back-channel logouts, and DPoP proofs across a Rails app's processes ([1019324](https://github.com/masksrb/masks/commit/1019324f74d44aa9df62d860082c529f5effda44))
9
+
10
+
11
+ ### Fixes
12
+
13
+ * **client:** a back-channel logout token must carry exp, and its jti is remembered until two minutes after it expires ([ffbf534](https://github.com/masksrb/masks/commit/ffbf53485583d342f0d6e147077b0c7df05c911f))
14
+ * **client:** a cache outage is told apart from a repeat, so masks and DPoP clients get 503 instead of a refusal ([8884cb6](https://github.com/masksrb/masks/commit/8884cb680e802521a413ea17a96eedfec58cae35))
15
+ * **client:** a page on another site can no longer sign somebody out of a Rails app ([96a084f](https://github.com/masksrb/masks/commit/96a084fe0172b27c1affe9461199335fe2b1bbba))
16
+ * **client:** a Rails sign-in keeps its tokens in its own cookies, split across up to eight, so a large access token no longer overflows the session ([88325fb](https://github.com/masksrb/masks/commit/88325fbee0d54bd90ffe3e7286dfa1553582ff2d))
17
+ * **client:** a sign-in callback this browser did not start no longer repeats the error text in its URL ([60574f2](https://github.com/masksrb/masks/commit/60574f263f11420933956354f3e1cae222cf0418))
18
+ * **client:** HTTP.fetch reads at most 8 MB, so the avatar proxy cannot be made to hold an endless answer ([541c0b5](https://github.com/masksrb/masks/commit/541c0b56d648d8bee2eb7cb82ca5ac061adfc350))
19
+ * **client:** signing out of masks returns to /auth/logout/return, which the handshake registers as the app's post-logout address ([d1dbc05](https://github.com/masksrb/masks/commit/d1dbc05e6bcbe41a4143b1efde901495dd6dbcae))
20
+ * **client:** the challenge for a request with no token carries no error description ([b030959](https://github.com/masksrb/masks/commit/b030959d6fd1b2e32be7f8173b17cde4282d43cc))
21
+ * **engine:** a handshake registers only the post-logout address it names, and keeps its return_to apart, so a new return_to is still asked about ([47a8988](https://github.com/masksrb/masks/commit/47a898874ee2c00f9763a57624cc3cb207a7e48f))
22
+ * **engine:** a refused token is described in masks' own words, not the JWT library's ([dae249d](https://github.com/masksrb/masks/commit/dae249d348c65e83bf96f680746d60ba4642c58d))
23
+
24
+
25
+ ### Documentation
26
+
27
+ * **client:** the to-do list records how close a Rails session sits to the cookie limit ([deedb14](https://github.com/masksrb/masks/commit/deedb144ba9458904348fa7482303d21691aefbf))
28
+ * comments and pages no longer claim what masks stopped doing ([e0b1ba0](https://github.com/masksrb/masks/commit/e0b1ba0b71ccae8683b605f9442ee16c4afb5dd4))
29
+ * every guide, SDK reference, and README says what masks does now, in fewer words ([7046f86](https://github.com/masksrb/masks/commit/7046f862c774f05407d92cd19779f53bf0b62595))
30
+
31
+
32
+ ### Refactoring
33
+
34
+ * **client:** HTTP.fetch is a GET through deliver with no read limit, and an embedded request's Host comes from Nonces.origin ([0398e13](https://github.com/masksrb/masks/commit/0398e135ba1a41fd56007709bb35aef900f14e57))
35
+
3
36
  ## [0.11.0](https://github.com/masksrb/masks/compare/gem/v0.10.0...gem/v0.11.0) (2026-10-13)
4
37
 
5
38
 
data/README.md CHANGED
@@ -2,54 +2,35 @@
2
2
 
3
3
  # masks
4
4
 
5
- Sign a Ruby or Rails app in against a [masks](https://github.com/masksrb/masks) issuer.
5
+ Sign people in to a Ruby or Rails app with a [masks](https://github.com/masksrb/masks) server, and
6
+ accept the tokens it gives out.
6
7
 
7
- ```ruby
8
- gem "masks"
9
- ```
8
+ The gem has two parts:
10
9
 
11
- The gem signs people in to an app, and accepts the tokens masks issues when the app is also a
12
- resource server. Its Rails engine loads only when Rails does, so a Sinatra, Hanami, or plain Rack
13
- app pulls in `jwt` and nothing else. See
14
- [Connecting via SDK](https://masks.pages.dev/guides/connecting-via-sdk/) for what it covers beside
15
- the browser package.
10
+ - `Masks::Rails`, a Rails engine that adds sign-in to your app. It loads only when Rails does.
11
+ - `Masks::Client`, plain Ruby for any app. It depends on `jwt` and nothing else.
16
12
 
17
- ## Rails
13
+ ## Install
18
14
 
19
- ```sh
20
- bin/rails generate masks:install --resource https://app.example.com
15
+ ```ruby
16
+ gem "masks"
21
17
  ```
22
18
 
23
- That mounts the engine at `/auth`, writes `config/initializers/masks.rb`, and gitignores
24
- the file credentials land in. Set `MASKS_ISSUER`, start the app, and open
25
- `/auth/handshake`. [Rails apps](https://masks.pages.dev/reference/ruby/) walks through the rest.
19
+ The gem needs Ruby 3.2 or later.
26
20
 
27
- ### The handshake
21
+ ## Rails
28
22
 
29
- An app connects to its issuer by somebody approving it. An unconnected app sends the browser to
30
- the issuer's approval screen, and the server redeems the one-time token that comes back, so the
31
- client secret never passes through the browser. The handshake registers the app for
32
- `config.resource`, an absolute URL on the app's own origin, and refuses without one. A connected app
33
- asks before it rotates, because reconnecting takes it offline for a moment.
23
+ 1. Run the install generator:
34
24
 
35
- Once an app is connected, only somebody `config.manages` approves can reconnect or disconnect it.
36
- It names a [privilege](#privileges), or is a callable taking the request, or the request and
37
- `masked`. The engine refuses everyone when it is unset:
25
+ ```sh
26
+ bin/rails generate masks:install --resource https://app.example.com
27
+ ```
38
28
 
39
- ```ruby
40
- config.manages = :admin
41
- config.manages = ->(request, masked) { masked.subject == ENV["OWNER_SUB"] }
42
- ```
29
+ 2. Set `MASKS_ISSUER` to the masks server and tenant you sign in against.
30
+ 3. Start the app and open `/auth/handshake`.
31
+ 4. Approve the app on the masks server.
43
32
 
44
- The engine writes what comes back to `config/masks.json`, mode 600. A multi-tenant app keeps it
45
- somewhere else with two callables:
46
-
47
- ```ruby
48
- config.credentials = ->(request) { Tenant.for(request).masks_credentials }
49
- config.store = ->(request, registration) { Tenant.for(request).connect!(registration) }
50
- ```
51
-
52
- ### Signing in
33
+ Then require a sign-in in a controller:
53
34
 
54
35
  ```ruby
55
36
  class DashboardController < ApplicationController
@@ -58,181 +39,13 @@ class DashboardController < ApplicationController
58
39
  masked!
59
40
 
60
41
  def index
61
- @who = masked.name
62
- end
63
- end
64
- ```
65
-
66
- `masked` is everything about the signed-in request in one object: `subject`, `name`, `nickname`,
67
- `email`, `picture`, `avatars`, `tenant`, `organization`, `organizations`, `role?`, `scopes`,
68
- `privileges`, `privilege?`, `access_token`, and `signed_in?`, with `masked["claim"]` for any other
69
- claim. In a view it is the same helper. Tokens live in the encrypted Rails session and never reach
70
- JavaScript.
71
-
72
- ### Privileges
73
-
74
- A privilege names what an action needs: scopes the person holds in masks, a role in the organization
75
- they signed in to, or both. A list is shorthand for `{ scope: [...] }`.
76
-
77
- ```ruby
78
- Masks::Rails.configure do |config|
79
- config.privileges = {
80
- admin: %w[catalog:admin],
81
- billing: { role: %w[owner billing] },
82
- acme_admin: { scope: %w[catalog:admin], role: "owner", organization: "acme" }
83
- }
84
- end
85
-
86
- class SettingsController < ApplicationController
87
- masked!
88
- masked! :admin, only: %i[create update destroy]
89
- end
90
- ```
91
-
92
- `masked!` with no privilege needs only a sign-in. A privilege's scopes stay out of every ordinary
93
- sign-in. When a signed-in person lacks one, the browser goes through masks once to ask for it and
94
- comes back, and the action answers `403` when masks does not grant it. A missing role or organization
95
- answers `403` at once. `masked.privilege?(:admin)` and `masked.privileges` tell a view what the sign-in
96
- holds, and `/auth/session` answers `privileges`. See
97
- [privileges](https://masks.pages.dev/reference/ruby/#privileges).
98
-
99
- `config.authenticate_everything = true` includes `Authentication` on every controller. Otherwise
100
- include it where it applies.
101
-
102
- ### A single-page app in front of it
103
-
104
- `GET /auth/session` answers identity, tenant, and scopes as JSON, or `401` with where to send the
105
- browser. [`@masks/client`](https://masks.pages.dev/reference/javascript/client/) reads it:
106
-
107
- ```js
108
- import { createSession } from "@masks/client"
109
-
110
- const session = createSession()
111
- const status = await session.status()
112
- ```
113
-
114
- `status()` answers `signed_in`, `signed_out` with where to sign in, or `handshake_required` with
115
- where to connect the app first.
116
-
117
- ### Accepting tokens
118
-
119
- An app that is also a resource server:
120
-
121
- ```ruby
122
- class ApiController < ApplicationController
123
- include Masks::Rails::ProtectedResource
124
-
125
- masked! :read, except: :metadata
126
-
127
- def metadata
128
- render json: masks_resource_metadata
42
+ @name = masked.name
129
43
  end
130
44
  end
131
45
  ```
132
46
 
133
- `masked!` checks a [privilege](#privileges) against the bearer token, here
134
- `config.privileges = { read: %w[xixo:catalog:read] }`. `masked` reads the verified token. A refusal carries the RFC 6750 challenge with
135
- `resource_metadata`, which points a client at `/.well-known/oauth-protected-resource`. The engine
136
- does not route that path, so route it to an action like `metadata` above. A DPoP-bound token is
137
- accepted only with a valid proof, as in [Masks::Client::Resource](#the-resource-server-half).
138
-
139
- ### Organizations
140
-
141
- A person signs in to an app as a member of one organization when the app asks for the
142
- `organization` scope. Someone in several organizations picks one, and an app can pick for them by
143
- naming it, with `/auth?organization=acme` or with `config.organization` for every sign-in.
144
-
145
- ```ruby
146
- Masks::Rails.configure do |config|
147
- config.scope = %w[openid profile email offline_access organization]
148
- config.organization = ->(request) { request.subdomain }
149
- end
150
-
151
- class BillingController < ApplicationController
152
- include Masks::Rails::Authentication
153
-
154
- masked! :billing
155
- end
156
- ```
157
-
158
- `masked.organization` is the organization signed in to (its `id`, `key`, `name`, and `role`), or
159
- `nil`, and `masked.role?("owner")` asks about the role held in it. A refresh reads the person's
160
- profile again, so a promotion or demotion reaches the app within an access token's lifetime.
161
- `/auth/session` answers the same thing as `organization`.
162
-
163
- `masked.organizations` lists every organization the person has joined, each with the role held in
164
- it, and `/auth/session` answers it as `organizations`. Switching sends the person through sign-in
165
- again naming the other one, which asks nothing more when they are already a member:
166
-
167
- ```erb
168
- <% masked.organizations.each do |held| %>
169
- <%= link_to held.name, masks_login_url(organization: held.key, return_to: request.path) %>
170
- <% end %>
171
- ```
172
-
173
- A privilege with a role refuses with 403, as `insufficient_organization` when the person signed in
174
- to none and `insufficient_role` when they hold another role. A resource server asks the same of a
175
- token with `masked!`.
176
-
177
- Naming an organization without asking for the scope still holds the sign-in to its members and its
178
- sign-in policy, and the app learns nothing about the role.
179
-
180
- ### Avatars
181
-
182
- Every account has three avatars: an uploaded `photo`, an `identicon`, and two-letter `initials`.
183
- All three arrive on the ID token.
184
-
185
- ```erb
186
- <img src="<%= masked.avatars.identicon %>?size=64" width="64" height="64" alt="">
187
- ```
188
-
189
- A photo needs a token, which an `<img>` cannot carry, so the engine proxies it with the token the
190
- app holds:
191
-
192
- ```erb
193
- <img src="/auth/avatar" width="64" height="64" alt="">
194
- ```
195
-
196
- `masked.picture` is the standard OIDC `picture` claim when the account set one, then the photo,
197
- then the identicon. `issuer.avatar_url(sub, style:, size:)` builds a URL for a subject this app holds
198
- no token for. Sizes are 32, 64, 128, 256, or 512.
199
-
200
- ### Signing out
201
-
202
- `DELETE /auth/logout` ends this app's session and revokes its refresh token at the issuer. With `?everywhere=1` the response also carries
203
- `logout_url`, the issuer's end-session endpoint. `config.sign_out_of_issuer = true` makes that the
204
- default for every sign-out.
205
-
206
- ### Configuration
207
-
208
- Every value that varies per request accepts a callable taking the request, which is what
209
- a subdomain-per-tenant host needs.
210
-
211
- | | |
212
- |---|---|
213
- | `issuer` | the masks issuer this app signs in against |
214
- | `resource` | this app's own identifier, which the handshake registers and tokens name as their audience |
215
- | `resource_scopes` | what it accepts, published in its RFC 9728 metadata |
216
- | `scope` | what to ask the issuer for, `openid profile email` by default |
217
- | `privileges` | named scopes, roles, and organizations that `masked!` checks, none by default |
218
- | `organization` | the organization every sign-in names, by key |
219
- | `credentials` / `store` | where the handshake's result lives |
220
- | `manages` | who may reconnect or disconnect a connected app |
221
- | `credentials_path` | where the default store writes, `config/masks.json` by default |
222
- | `after_sign_in` / `after_sign_out` | paths on this host |
223
- | `parent_controller` | what the engine's pages inherit, for your layout |
224
- | `authenticate_everything` | include `Authentication` on every controller |
225
- | `sign_out_of_issuer` | make every sign-out an RP-initiated logout |
226
- | `session_key` | the session key the tokens live under |
227
- | `delegates` / `delegation_redirect_uri` | ask for delegation in the handshake, and where connecting an account returns |
228
- | `replay` | where seen DPoP proofs are remembered, any object with `first?(key, expires_in:)`, this process's memory by default |
229
-
230
47
  ## Any Ruby app
231
48
 
232
- The Rails engine is built on `Masks::Client`, which any Ruby app can use directly.
233
-
234
- ### The consumer half
235
-
236
49
  ```ruby
237
50
  session = Masks::Client::Session.new(
238
51
  issuer: "https://demo.auth.example.com",
@@ -240,267 +53,21 @@ session = Masks::Client::Session.new(
240
53
  redirect_uri: "https://app.example.com/auth/callback"
241
54
  )
242
55
 
243
- started = session.start(resource: "https://app.example.com/mcp")
244
-
245
- tokens = session.complete(code: params[:code], verifier: held[:verifier])
56
+ started = session.start
57
+ tokens = session.complete(code: params[:code], verifier: started[:verifier])
246
58
  identity = session.identity(tokens)
247
59
  ```
248
60
 
249
- Hold `started[:state]`, `started[:nonce]`, and `started[:verifier]` in the session, and send the
250
- browser to `started[:url]`. `start` sends a nonce only when the scope includes `openid`. Check the
251
- returned `state` yourself, and compare `identity["nonce"]` with the one you held.
252
-
253
- `Session` also has `refresh`, `exchange`, `client_credentials`, `device_authorization`,
254
- `await_device`, `enroll`, `unenroll`, `revoke`, `introspect`, `userinfo`, `end_session_url`, and
255
- `transport`. It authenticates with `client_secret`, or with `private_key` and `key_id` for
256
- `private_key_jwt`. A `Masks::Client::Key` passed as `private_key` names itself by its thumbprint, so
257
- `key_id` can be left out.
258
-
259
- `userinfo` takes `Tokens` or an access token string. A string and a `Bearer` token go as `Bearer`.
260
- A DPoP-bound token goes as `Authorization: DPoP` with a proof from the session's `dpop:` key, and
261
- raises `Masks::Client::Error` when the session holds no key.
262
-
263
- An app that has claimed a namespace enrolls a machine with `enroll`. It returns an
264
- `Enrollment` whose `initial_access_token` the machine spends once to register its own public key.
265
-
266
- ```ruby
267
- enrollment = session.enroll(scope: "xixo:agent", name: "nas", expires_in: 900)
268
- enrollment.initial_access_token
269
- ```
270
-
271
- `unenroll` takes the machine's client ID. masks archives the machine and revokes its tokens, and
272
- refuses a client the app did not enroll.
273
-
274
- ```ruby
275
- session.unenroll(machine_client_id)
276
- ```
277
-
278
- ### A machine
279
-
280
- A machine enrolled by an app makes two P-256 keys: one to sign its client assertions, and one to
281
- hold its tokens with DPoP. It registers the public half of the first with the app's initial access
282
- token, and keeps both private halves to itself.
283
-
284
- ```ruby
285
- key = Masks::Client::Key.generate
286
- dpop = Masks::Client::Key.generate
287
-
288
- registration = Masks::Client::Registration.create(
289
- "https://demo.auth.example.com", token: initial_access_token,
290
- name: "nas", jwks: key, token_endpoint_auth_method: "private_key_jwt",
291
- grant_types: [ "client_credentials" ], dpop_bound_access_tokens: true
292
- )
293
-
294
- File.write("machine.json", { "client_id" => registration.client_id, "key" => key.to_h,
295
- "dpop" => dpop.to_h }.to_json, perm: 0o600)
296
-
297
- session = registration.session(private_key: key, dpop: dpop)
298
- tokens = session.client_credentials(scope: "xixo:agent")
299
- tokens.bound?
300
- ```
301
-
302
- `Registration.create` sends `grant_types` and `response_types` only when they are given, and masks
303
- fills in the rest: an enrolled machine gets `client_credentials`, and an open registration gets
304
- `authorization_code` and `code`. `jwks:` takes a `Key`, a list of them, or a JWK Set. `Key#jwks` and `Key#public_jwk` hold only the
305
- public half, and `Key#to_h` holds the private one. `Key.parse` reads a key back from that hash, its
306
- JSON, or a PEM. A key on another curve, or a public key, raises `ArgumentError`.
307
-
308
- `Registration.create` with a `token:` posts to `/register` even when discovery publishes no
309
- `registration_endpoint`, because a tenant that turns open registration off still redeems initial
310
- access tokens there. `read`, `update`, and `delete` raise `Masks::Client::Foreign` when the
311
- `registration_client_uri` names an origin other than the issuer's, and send nothing.
312
-
313
- A `Session` given `dpop:` sends a new DPoP proof with every request to the token endpoint:
314
- `complete`, `client_credentials`, `refresh`, `exchange`, and `await_device`. A server that answers
315
- `use_dpop_nonce` with a `DPoP-Nonce` header gets one retry carrying that nonce, and the nonce is
316
- kept for that origin's next request. A token endpoint that answers a proven request with a `Bearer`
317
- token is refused with `invalid_dpop_proof`. Revocation, introspection, enrollment, and device
318
- authorization are sent without a proof.
319
-
320
- `transport` sends requests to a resource with those tokens. It takes the origins it may send them to
321
- and raises `Masks::Client::Foreign` for any other URL before it fetches a token. An origin in plain
322
- `http` follows the same rule as the issuer, and raises `Masks::Client::Insecure` when the transport
323
- is built. A resource's answers are not limited to 1 MB.
324
-
325
- ```ruby
326
- api = session.transport("https://app.example.com", scope: "xixo:agent")
327
-
328
- response = api.get("https://app.example.com/mcp/feeds?page=2")
329
- api.post("https://app.example.com/mcp/feeds", body: { "name" => "notes" })
330
- ```
331
-
332
- Each request carries `Authorization: DPoP` and a proof naming the method, the URL without its query,
333
- and the token's hash in `ath`. A resource that answers `401` with `use_dpop_nonce` in
334
- `WWW-Authenticate` and a new `DPoP-Nonce` is asked once more. The answer is a `Net::HTTPResponse`,
335
- which the transport returns whatever its status. A `Bearer` token goes as `Bearer` with no proof.
336
-
337
- Without `tokens:`, the transport asks for `client_credentials` with the `scope` and `resource` it was
338
- given, and again when they expire. With `tokens:`, it renews them with their refresh token, keeps that
339
- refresh token when the server does not rotate it, and yields the new tokens so they can be stored:
340
-
341
- ```ruby
342
- api = session.transport("https://app.example.com", tokens: held) { |tokens| store(tokens.to_h) }
343
- ```
344
-
345
- `Masks::Client::Transport.new(*origins, tokens:, key:)` builds one around tokens you already hold.
346
-
347
- ### The device flow
348
-
349
- A device with no browser asks for a code, shows it, and polls until somebody approves it:
350
-
351
- ```ruby
352
- device = session.device_authorization(scope: %w[openid offline_access], resource: "https://app.example.com/mcp")
353
-
354
- puts "Open #{device.verification_uri} and enter #{device.user_code}"
355
-
356
- tokens = session.await_device(device)
357
- ```
358
-
359
- `await_device` waits `interval` seconds between polls, five when the server names none, and five
360
- seconds longer after each `slow_down`. It keeps polling through `authorization_pending`. It raises
361
- `Masks::Client::Rejected` with the server's code for `access_denied`, `expired_token`, or any other
362
- refusal, and raises `expired_token` without polling once `device.expires_at` has passed. A session
363
- holding a DPoP key proves each poll, and the tokens it returns are held to that key.
364
-
365
- Discovery and JWKS are cached for five minutes and refetched on an unknown `kid`, so a key rotation
366
- needs no restart. The cache lives on the `Issuer`, so use `Masks::Client::Issuer.resolve` instead
367
- of constructing one per request.
368
-
369
- Every request the gem makes goes over `https`, or plain `http` to `localhost`, a `*.localhost`
370
- name, or a loopback address. Any other `http` URL raises `Masks::Client::Insecure` before anything is
371
- sent. A deployment that reaches its issuer over a private network in plain `http` sets
372
- `Masks::Client.plain_http = true`. Answers from the issuer are read up to 1 MB, and an answer that
373
- is not a JSON object raises `Masks::Client::Rejected` with the code `invalid_response`.
374
-
375
- ### The handshake
376
-
377
- The flow that connects a first-party app:
378
-
379
- ```ruby
380
- handshake = Masks::Client::Handshake.new(
381
- issuer, name: "xixo", resource: "https://app.example.com/mcp",
382
- redirect_uris: [ "https://app.example.com/auth/callback" ],
383
- return_to: "https://app.example.com/"
384
- )
385
-
386
- started = handshake.start
387
- registration = handshake.complete(params, state: held)
388
- ```
389
-
390
- Hold `started[:state]` and send the browser to `started[:url]`. Before it redeems anything,
391
- `complete` refuses an `error`, a `state` that does not match this browser, and an `iss` other than
392
- the issuer it asked.
393
-
394
- ### The resource-server half
395
-
396
- ```ruby
397
- resource = Masks::Client::Resource.new(
398
- issuer: "https://demo.auth.example.com",
399
- url: "https://app.example.com/mcp",
400
- scopes: { "xixo:catalog:read" => "Search your catalog" }
401
- )
402
-
403
- claims = resource.authenticate(
404
- request.authorization, scope: "xixo:catalog:read",
405
- proof: request.get_header("HTTP_DPOP"), method: request.request_method, url: request.url
406
- )
407
- claims.subject
408
- claims.tenant.subdomain
409
- ```
410
-
411
- `authenticate` answers `Claims` or raises. A token bound to a key with DPoP carries `cnf.jkt`, and is
412
- accepted only as `Authorization: DPoP` with a proof signed by that key for this method, URL, and
413
- token, made within the last minute and never seen before. `proof`, `method`, and `url` are what it
414
- checks the proof against. Seen proofs are kept in the process's memory. A deployment with several
415
- processes passes `replay:` to `Resource.new`, or sets `config.replay` in a Rails app, to any object
416
- whose `first?(key, expires_in:)` answers true only once for each key across every process.
417
-
418
- When that store raises, `authenticate` raises `Masks::Client::ReplayUnavailable`, which is not a
419
- challenge. The Rack middleware and a Rails controller answer `503` with `temporarily_unavailable`
420
- and no `WWW-Authenticate` header, so a client keeps a token that is still good. A proof the store
421
- has already seen is still refused with `401`.
422
-
423
- A refusal builds the RFC 6750 challenge carrying `resource_metadata` and the scopes it would have
424
- accepted:
425
-
426
- ```ruby
427
- response.headers["WWW-Authenticate"] = resource.challenge(error)
428
- ```
429
-
430
- `resource.metadata` is the RFC 9728 document to serve at `/.well-known/oauth-protected-resource`.
431
- Its `scope_descriptions` extension gives masks the sentences its consent screen shows for each
432
- scope.
433
-
434
- A Rack middleware does the same below the framework, proof included:
435
-
436
- ```ruby
437
- use Masks::Client::Rack, resource: resource, scope: "xixo:catalog:read"
438
- ```
439
-
440
- ### Introspection
441
-
442
- A resource server that only verifies the JWT sees a revocation when the token expires. Introspection
443
- asks the issuer:
444
-
445
- ```ruby
446
- found = session.introspect(token)
447
- found.active? && found.permits?("xixo:catalog:read")
448
- ```
449
-
450
- `Introspection` is a `Claims` whose `permit!` also raises when the token is not active.
451
-
452
- ### Somebody else's account
453
-
454
- An app that acts as somebody at Google, Microsoft, or an MCP server while they are away asks masks for
455
- a [delegation](https://masks.pages.dev/guides/connecting-via-sdk/#delegation). masks keeps and refreshes the
456
- provider's tokens. The app keeps one secret per connection and trades it for a live access token when
457
- the last one runs out.
458
-
459
- ```ruby
460
- delegations = Masks::Client.delegations(
461
- ENV["MASKS_ISSUER"], client_id: id, client_secret: secret, redirect_uri: "https://app.test/connect/callback"
462
- )
463
-
464
- started = delegations.start(provider: "google")
465
- session[:connecting] = started
466
- redirect_to started["url"]
467
-
468
- held = delegations.finish(params: params, started: session.delete(:connecting))
469
- held.connection
470
- held.secret
471
-
472
- upstream = delegations.token(held.secret, connection: held.connection)
473
- upstream.access_token
474
- upstream.expires_at
475
- upstream.secret
476
- ```
477
-
478
- A Rails app sets `config.delegates = true`, so its handshake asks for `masks:delegate:` and the token
479
- exchange with it.
480
-
481
- `upstream.secret` replaces the one you passed in, every time. `Delegations::Refused` means somebody
482
- has to connect again, and `Delegations::Unavailable` is worth retrying. Both carry `secret` when masks
483
- had already rotated it, so keep it. Spend a secret from one place at a time, because spending one
484
- twice revokes it.
485
-
486
- Tests use the fake, which needs no issuer:
487
-
488
- ```ruby
489
- require "masks/client/delegations/fake"
490
-
491
- fake = Masks::Client::Delegations::Fake.new
492
- started = fake.start(provider: "notion")
493
- held = fake.finish(params: fake.approve(started), started: started)
494
-
495
- fake.token(held.secret, connection: held.connection)
496
- fake.revoke(held.connection)
497
- fake.unavailable(held.connection)
498
- ```
61
+ Keep `started[:state]`, `started[:nonce]`, and `started[:verifier]` in the session, and send the
62
+ browser to `started[:url]`. When it comes back, check `state` and `identity["nonce"]` against the
63
+ values you kept.
499
64
 
500
65
  ## Documentation
501
66
 
502
- The full reference is generated from this gem at [masks.pages.dev](https://masks.pages.dev): `ruby`
503
- for `Masks::Client` and `rails` for the engine.
67
+ - [Rails apps](https://masks.pages.dev/reference/ruby/) covers the handshake, privileges,
68
+ organizations, accepting tokens, and running several processes.
69
+ - [Masks::Client](https://masks.pages.dev/reference/ruby/client/) and
70
+ [Masks::Rails](https://masks.pages.dev/reference/ruby/rails/) list every class and setting.
504
71
 
505
72
  ## License
506
73
 
@@ -1,7 +1,6 @@
1
1
  module Masks
2
2
  module Rails
3
3
  class HandshakesController < BaseController
4
- prepend_before_action :signed_out_upstream, only: :callback
5
4
  before_action :require_unconfigured_or_signed_in
6
5
  before_action :require_unconfigured_or_manager
7
6
 
@@ -30,7 +29,7 @@ module Masks
30
29
  end
31
30
 
32
31
  def destroy
33
- return redirect_to(masks_config.after_sign_out) unless masks_signed_in?
32
+ return redirect_to(masks_after_sign_out_path) unless masks_signed_in?
34
33
  return redirect_to(masks_handshake_path) unless masks_config.can_forget?
35
34
 
36
35
  forget_upstream
@@ -45,13 +44,6 @@ module Masks
45
44
 
46
45
  private
47
46
 
48
- def signed_out_upstream
49
- return if params[:initial_access_token].present? || params[:error].present?
50
- return stale if masks_farewells.claim(params[:state].to_s).nil?
51
-
52
- redirect_to masks_after_sign_out_path
53
- end
54
-
55
47
  def begin!
56
48
  pending = masks_handshakes.open
57
49
  started = masks_config.handshake_for(request).start(state: pending.id)
@@ -75,7 +67,7 @@ module Masks
75
67
  return unless masks_config.configured?(request)
76
68
  return if masks_signed_in?
77
69
 
78
- redirect_to masks_config.after_sign_out
70
+ redirect_to masks_after_sign_out_path
79
71
  end
80
72
 
81
73
  def require_unconfigured_or_manager
@@ -7,25 +7,28 @@ module Masks
7
7
  return refuse("invalid_request", "logout_token is required") if params[:logout_token].blank?
8
8
 
9
9
  logout = masks_session.logout_token(params[:logout_token])
10
+ issuer = masks_config.issuer_for(request).to_s
10
11
 
11
- heard = masks_config.logged_out!(request, logout)
12
+ return refuse("invalid_request", "that logout token was already used") unless masks_cache.first_logout?(issuer, logout)
13
+
14
+ masks_cache.depart!(issuer, logout)
15
+ masks_config.logged_out!(request, logout)
12
16
 
13
17
  response.headers["Cache-Control"] = "no-store"
14
18
 
15
- head(heard ? :ok : :not_implemented)
16
- rescue Masks::Client::InvalidToken => e
17
- refuse("invalid_request", e.message)
18
- rescue Masks::Rails::Configuration::Unconfigured => e
19
+ head :ok
20
+ rescue Masks::Client::InvalidToken, Masks::Rails::Configuration::Unconfigured => e
19
21
  refuse("invalid_request", e.message)
22
+ rescue Masks::Client::Unreachable, Masks::Rails::Cache::Unavailable => e
23
+ refuse("temporarily_unavailable", e.message, status: :service_unavailable)
20
24
  end
21
25
 
22
26
  private
23
27
 
24
- def refuse(code, description)
28
+ def refuse(code, description, status: :bad_request)
25
29
  response.headers["Cache-Control"] = "no-store"
26
30
 
27
- render json: { "error" => code, "error_description" => description },
28
- status: :bad_request
31
+ render json: { "error" => code, "error_description" => description }, status: status
29
32
  end
30
33
  end
31
34
  end