masks 0.7.0 → 0.9.1

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: 5da19b0cb3099ebe46f2b4509615a75422fa0b2820abb799e8f182ce11f456ad
4
- data.tar.gz: 4d5d6b573cccbca63439587cd3363d22be28dd0d8ff95051f32dcc8252494185
3
+ metadata.gz: 62fc7998cf31199eaa81f63d82063e3e20ab995c74c782c675e91628df7dcee3
4
+ data.tar.gz: 6d83a9474d9289c375acfcacd6c5c4e45a66a92d22e1683abba9780de3c63d43
5
5
  SHA512:
6
- metadata.gz: 130ccd4e8a1ca7ddfe996f7439af4dc800501ea0a81ec0a18561475447931af40affb0cd0e2a66aa191d28d93f677046f49b7d5db6a913426fa21114d1bcbb68
7
- data.tar.gz: bd818590edc19a344daf59e755d8657da71e10f2017fc3b8ffe95ed6f8452f804a6ccdc7da621425b288c5934b25b5c39bd41923d13983e16fab9ab144bdfd78
6
+ metadata.gz: 97164bf9ab0ee6820e353a9dad5d97141ddf0cd038dd3a6348e03e46eea46313643e13f8d49c17bd414c16420e4b25c5c29072bd4efea114f3483e4174c0e599
7
+ data.tar.gz: 505cca23a8b1b0f439365eea100780b64157700ab9995df53fa46df640a0efc1da9a64e95281925279233ea4e4036563e41aebd727d8b7f2795991dac3604931
data/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.1](https://github.com/masksrb/masks/compare/gem/v0.9.0...gem/v0.9.1) (2026-10-04)
4
+
5
+
6
+ ### Fixes
7
+
8
+ * **client:** an error the issuer sends back is shown rather than retried, and the engine tests hold the new callback behaviour ([36adb9b](https://github.com/masksrb/masks/commit/36adb9b57a2379b8d7ce1d16f2e04e8c244baa7d))
9
+
10
+ ## [0.9.0](https://github.com/masksrb/masks/compare/gem/v0.8.0...gem/v0.9.0) (2026-10-04)
11
+
12
+
13
+ ### ⚠ BREAKING CHANGES
14
+
15
+ * **client:** a connected app refuses reconnecting and disconnecting until config.manages is set, for example config.manages = ->(request, identity) { identity["sub"] == ENV["OWNER_SUB"] }.
16
+
17
+ ### Fixes
18
+
19
+ * **client:** a resource server refuses a DPoP-bound token presented as Bearer, and checks the proof one presented as DPoP ([9a3fe8a](https://github.com/masksrb/masks/commit/9a3fe8adac09fd70a7059674d8c34333c8f3ff92))
20
+ * **client:** a return_to a browser would read as another host is refused, and signing out revokes the refresh token ([de27168](https://github.com/masksrb/masks/commit/de2716872955ba124a8a3ec1dbe07cb043fd0260))
21
+ * **client:** a sign-in callback this browser no longer holds goes on to the app or starts sign-in again, instead of ending on an error ([a1ba84c](https://github.com/masksrb/masks/commit/a1ba84c4a974897bdb80b1f32da5c0b72f83ca79))
22
+ * **client:** every resource in a process shares one store of seen DPoP proofs, and a refused proof is challenged with the DPoP scheme ([f8d1fe2](https://github.com/masksrb/masks/commit/f8d1fe2487e0705c914ea3d3ae7f686152cad6b3))
23
+ * **client:** only somebody config.manages approves can reconnect or disconnect a connected app ([5665129](https://github.com/masksrb/masks/commit/566512979dc00a36148de57b4ea9954fc68db10b))
24
+
25
+
26
+ ### Documentation
27
+
28
+ * every guide, the reference prose, and the READMEs are checked against the code, shortened, and corrected ([94782d1](https://github.com/masksrb/masks/commit/94782d1fbfe363e90c40f0c728a241de9738e138))
29
+ * the plan holds only what is left, and the READMEs match the engine layout and the code ([1615722](https://github.com/masksrb/masks/commit/1615722f88ae5f7d19955ff686dcef9f64a6326f))
30
+
31
+
32
+ ### Refactoring
33
+
34
+ * **client:** the issuer version check nothing called, Registry#urls and Verifier#tenant go ([6b8cf05](https://github.com/masksrb/masks/commit/6b8cf0550b96f6b1b3b415ff27f655f90614bdfe))
35
+ * **engine:** deleting and downloading an account share one check for a fresh sign-in, and PKCE uses the client's one base64url digest ([6c08fcf](https://github.com/masksrb/masks/commit/6c08fcf709a08dbafd0a551801eaf43a4c325b75))
36
+ * **engine:** one partial carries a tenant's email wording, one query loads it, and authorization_details narrow in one place ([1b2df29](https://github.com/masksrb/masks/commit/1b2df29380b2c2c8dc6b87d45d60b4ef23255b26))
37
+
38
+ ## [0.8.0](https://github.com/masksrb/masks/compare/gem/v0.7.0...gem/v0.8.0) (2026-09-28)
39
+
40
+
41
+ ### ⚠ BREAKING CHANGES
42
+
43
+ * a resource server on an older gem still accepts the new tokens, but this gem refuses access tokens from a server that does not type them.
44
+
45
+ ### Features
46
+
47
+ * an access token is typed at+jwt, and nothing takes a token of another type in its place ([d0b4cfd](https://github.com/masksrb/masks/commit/d0b4cfdf802053359695495d417225e3a1578302))
48
+ * **client:** a Rails app lists the organizations a person belongs to and links them to another ([7f0d223](https://github.com/masksrb/masks/commit/7f0d2237799e04551d2c403de695c704e5455db8))
49
+ * **client:** a Rails app names the organization a person signs in to, reads their role, and keeps pages and APIs to members in a role ([579cecc](https://github.com/masksrb/masks/commit/579ceccac1c0137dfa1ed7a3894dea61e5cdd67e))
50
+ * **client:** a session signs its own client assertions, and asks for a token of its own ([614248b](https://github.com/masksrb/masks/commit/614248b70a3ee517fbd3c1f7151b66ec6406b487))
51
+ * **client:** an exchange hands over an ID token or an actor token ([6b67766](https://github.com/masksrb/masks/commit/6b67766ce275b9578d575349256638adddd5a599))
52
+ * **client:** the session says where the person manages their account, as account_url ([1d36e1d](https://github.com/masksrb/masks/commit/1d36e1d4daa8c5acfc9133a1896aa9384771dbd6))
53
+
54
+
55
+ ### Documentation
56
+
57
+ * Rails apps covers client, server, and engine mode ([41454a2](https://github.com/masksrb/masks/commit/41454a242438e4df246dcbc4dce94b6d8a022239))
58
+
59
+
60
+ ### Refactoring
61
+
62
+ * **client:** an organization key is checked in one place, and a refresh renews the whole identity from userinfo ([b349aae](https://github.com/masksrb/masks/commit/b349aaec4dd5c9790c72dd1dc8f91016a2adceea))
63
+
3
64
  ## [0.7.0](https://github.com/masksrb/masks/compare/gem/v0.6.0...gem/v0.7.0) (2026-09-13)
4
65
 
5
66
 
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- <p align="center"><img src="https://raw.githubusercontent.com/masksrb/masks/main/server/public/icon.svg" width="120" alt="The masks rose window"></p>
1
+ <p align="center"><img src="https://raw.githubusercontent.com/masksrb/masks/main/engine/public/masks-public/icon.svg" width="120" alt="The masks rose window"></p>
2
2
 
3
3
  # masks
4
4
 
@@ -8,53 +8,50 @@ Sign a Ruby or Rails app in against a [masks](https://github.com/masksrb/masks)
8
8
  gem "masks"
9
9
  ```
10
10
 
11
- Three parts, in one gem. **Consumers spend tokens. Resource servers accept them.** Most
12
- client libraries build only the first, leaving each resource server to write its own
13
- scope check, its `WWW-Authenticate` header and its metadata document. Both halves are
14
- here, with a Rails engine that mounts them.
15
-
16
- The engine loads only when Rails does. A Sinatra, Hanami or plain-Rack app requiring
17
- this gem pulls in `jwt` and nothing else.
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.
18
16
 
19
17
  ## Rails
20
18
 
21
- One command to install, and the app is connected by somebody approving it — no client
22
- id to copy, no secret to paste anywhere.
23
-
24
- ```
25
- bin/rails generate masks:install
19
+ ```sh
20
+ bin/rails generate masks:install --resource https://app.example.com
26
21
  ```
27
22
 
28
23
  That mounts the engine at `/auth`, writes `config/initializers/masks.rb`, and gitignores
29
24
  the file credentials land in. Set `MASKS_ISSUER`, start the app, and open
30
- `/auth/handshake`.
25
+ `/auth/handshake`. [Rails apps](https://masks.pages.dev/guides/rails/) walks through the rest.
31
26
 
32
27
  ### The handshake
33
28
 
34
- A first-party app must not self-register anonymously, since that is also how a
35
- stranger's connector arrives. An unconnected app sends the browser straight on to its
36
- own issuer's approval screen, and the one-time token that comes back is redeemed
37
- server-side. **The secret never travels through the browser and nobody types it
38
- anywhere.**
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.
39
34
 
40
- The only thing `/auth/handshake` stops a browser to say is that the app is configured
41
- too little to shake hands at all, or that the issuer refused. A connected app asks
42
- before it rotates, since reconnecting takes it offline for a moment.
35
+ Once an app is connected, only somebody `config.manages` approves can reconnect or disconnect it.
36
+ The callable takes the request, or the request and the signed-in identity, and the engine refuses
37
+ everyone when it is unset:
43
38
 
44
- The engine writes what comes back to `config/masks.json`, mode 600. An app that wants
45
- somewhere else says so:
39
+ ```ruby
40
+ config.manages = ->(request, identity) { identity["sub"] == ENV["OWNER_SUB"] }
41
+ ```
42
+
43
+ The engine writes what comes back to `config/masks.json`, mode 600. A multi-tenant app keeps it
44
+ somewhere else with two callables:
46
45
 
47
46
  ```ruby
48
47
  config.credentials = ->(request) { Tenant.for(request).masks_credentials }
49
48
  config.store = ->(request, registration) { Tenant.for(request).connect!(registration) }
50
49
  ```
51
50
 
52
- Those two lambdas are the whole integration for a multi-tenant app.
53
-
54
51
  ### Signing in
55
52
 
56
53
  ```ruby
57
- class UrisController < ApplicationController
54
+ class DashboardController < ApplicationController
58
55
  include Masks::Rails::Authentication
59
56
 
60
57
  before_action :authenticate_masks!
@@ -65,18 +62,16 @@ class UrisController < ApplicationController
65
62
  end
66
63
  ```
67
64
 
68
- `masks_identity`, `masks_tenant` and `masks_scopes` are what a signed-in request carries.
69
- Tokens live in the encrypted Rails session and never reach JavaScript. This is the
70
- backend-for-frontend pattern, and it is the default because an SPA holding a token is an
71
- SPA where XSS lifts one.
65
+ `masks_identity`, `masks_tenant`, and `masks_scopes` describe the signed-in request. Tokens live in
66
+ the encrypted Rails session and never reach JavaScript.
72
67
 
73
- The include is **not** blanket. `config.authenticate_everything = true` puts it on every
74
- controller if that is what you want; otherwise include it where you mean it.
68
+ `config.authenticate_everything = true` includes `Authentication` on every controller. Otherwise
69
+ include it where it applies.
75
70
 
76
- ### An SPA in front of it
71
+ ### A single-page app in front of it
77
72
 
78
- `GET /auth/session` answers identity, tenant and scopes as JSON, or `401` with somewhere
79
- to send the browser. [`@masks/client`](https://masks.pages.dev/reference/browser/) speaks it:
73
+ `GET /auth/session` answers identity, tenant, and scopes as JSON, or `401` with where to send the
74
+ browser. [`@masks/client`](https://masks.pages.dev/reference/browser/) reads it:
80
75
 
81
76
  ```js
82
77
  import { createSession } from "@masks/client"
@@ -85,9 +80,8 @@ const session = createSession()
85
80
  const status = await session.status()
86
81
  ```
87
82
 
88
- `status()` answers one of three states: `signed_in`, `signed_out` with where to sign in,
89
- and `handshake_required` with where to go instead. The third exists because an app nobody
90
- has connected must not offer a sign-in button that leads to an error page at the issuer.
83
+ `status()` answers `signed_in`, `signed_out` with where to sign in, or `handshake_required` with
84
+ where to connect the app first.
91
85
 
92
86
  ### Accepting tokens
93
87
 
@@ -97,43 +91,90 @@ An app that is also a resource server:
97
91
  class ApiController < ApplicationController
98
92
  include Masks::Rails::ProtectedResource
99
93
 
100
- masks_protect! scope: "uris:catalog:read"
94
+ masks_protect! scope: "uris:catalog:read", except: :metadata
95
+
96
+ def metadata
97
+ render json: masks_resource_metadata
98
+ end
101
99
  end
102
100
  ```
103
101
 
104
- `masks_protect!` is a class method that installs the `before_action` itself, and passes `:only` and
105
- `:except` through.
106
-
102
+ `masks_protect!` installs the `before_action` and passes `:only` and `:except` through.
107
103
  `masks_claims` is the verified token. A refusal carries the RFC 6750 challenge with
108
- `resource_metadata`, so a client handed nothing but a URL can find its way to the issuer
109
- and back.
104
+ `resource_metadata`, which points a client at `/.well-known/oauth-protected-resource`. The engine
105
+ does not route that path, so route it to an action like `metadata` above. A DPoP-bound token is
106
+ accepted only with a valid proof, as in [Masks::Client::Resource](#the-resource-server-half).
107
+
108
+ ### Organizations
109
+
110
+ A person signs in to an app as a member of one organization when the app asks for the
111
+ `organization` scope. Someone in several organizations picks one, and an app can pick for them by
112
+ naming it, with `/auth?organization=acme` or with `config.organization` for every sign-in.
113
+
114
+ ```ruby
115
+ Masks::Rails.configure do |config|
116
+ config.scope = %w[openid profile email offline_access organization]
117
+ config.organization = ->(request) { request.subdomain }
118
+ end
119
+
120
+ class BillingController < ApplicationController
121
+ include Masks::Rails::Authentication
122
+
123
+ masks_members_only! role: %w[owner billing]
124
+ end
125
+ ```
126
+
127
+ `masks_organization` is the organization signed in to (its `id`, `key`, `name`, and `role`), or
128
+ `nil`, and `masks_role?("owner")` asks about the role held in it. A refresh reads the person's
129
+ profile again, so a promotion or demotion reaches the app within an access token's lifetime.
130
+ `/auth/session` answers the same thing as `organization`.
131
+
132
+ `masks_organizations` lists every organization the person has joined, each with the role held in
133
+ it, and `/auth/session` answers it as `organizations`. Switching sends the person through sign-in
134
+ again naming the other one, which asks nothing more when they are already a member:
135
+
136
+ ```erb
137
+ <% masks_organizations.each do |held| %>
138
+ <%= link_to held.name, masks_login_url(organization: held.key, return_to: request.path) %>
139
+ <% end %>
140
+ ```
141
+
142
+ `masks_members_only!` refuses with 403, as `insufficient_organization` when the person signed
143
+ in to none and `insufficient_role` when they hold another role. A resource server asks the same of a
144
+ token:
145
+
146
+ ```ruby
147
+ masks_protect! scope: "uris:catalog:write", role: "owner", organization: "acme"
148
+ ```
149
+
150
+ Naming an organization without asking for the scope still holds the sign-in to its members and its
151
+ sign-in policy, and the app learns nothing about the role.
110
152
 
111
153
  ### Avatars
112
154
 
113
- Every actor has three faces at once — an uploaded `photo`, an `identicon`, and two-letter
114
- `initials`. All three arrive on the id token, so drawing one costs no request.
155
+ Every account has three avatars: an uploaded `photo`, an `identicon`, and two-letter `initials`.
156
+ All three arrive on the ID token.
115
157
 
116
158
  ```erb
117
159
  <img src="<%= masks_claims.avatars.identicon %>?size=64" width="64" height="64" alt="">
118
160
  ```
119
161
 
120
- A photo is a likeness of a person and needs a token, which an `<img>` cannot carry, so the engine
121
- proxies it with the token this app already holds:
162
+ A photo needs a token, which an `<img>` cannot carry, so the engine proxies it with the token the
163
+ app holds:
122
164
 
123
165
  ```erb
124
166
  <img src="/auth/avatar" width="64" height="64" alt="">
125
167
  ```
126
168
 
127
- `masks_claims.picture` resolves the standard OIDC claim — an offsite `picture_url` if the actor set
128
- one, then the photo, then the identicon. `issuer.avatar_url(sub, style:, size:)` builds a URL for a
129
- subject this app holds no token for. Sizes are 32, 64, 128, 256 or 512.
169
+ `masks_claims.picture` is the standard OIDC `picture` claim when the account set one, then the photo,
170
+ then the identicon. `issuer.avatar_url(sub, style:, size:)` builds a URL for a subject this app holds
171
+ no token for. Sizes are 32, 64, 128, 256, or 512.
130
172
 
131
173
  ### Signing out
132
174
 
133
- `DELETE /auth/logout` ends this app's session. Add `?everywhere=1` and the response
134
- carries `logout_url` — the issuer's end-session endpoint — so signing out can mean
135
- signing out. `config.sign_out_of_issuer = true` makes that the default for every
136
- sign-out.
175
+ `DELETE /auth/logout` ends this app's session and revokes its refresh token at the issuer. With `?everywhere=1` the response also carries
176
+ `logout_url`, the issuer's end-session endpoint. `config.sign_out_of_issuer = true` makes that the
177
+ default for every sign-out.
137
178
 
138
179
  ### Configuration
139
180
 
@@ -143,21 +184,23 @@ a subdomain-per-tenant host needs.
143
184
  | | |
144
185
  |---|---|
145
186
  | `issuer` | the masks issuer this app signs in against |
146
- | `resource` | this app's own identifier, when it also accepts tokens |
187
+ | `resource` | this app's own identifier, which the handshake registers and tokens name as their audience |
147
188
  | `resource_scopes` | what it accepts, published in its RFC 9728 metadata |
148
- | `scope` | what to ask the issuer for; defaults to `openid profile email` |
189
+ | `scope` | what to ask the issuer for, `openid profile email` by default |
190
+ | `organization` | the organization every sign-in names, by key |
149
191
  | `credentials` / `store` | where the handshake's result lives |
150
- | `credentials_path` | where the default store writes; `config/masks.json` |
192
+ | `manages` | who may reconnect or disconnect a connected app |
193
+ | `credentials_path` | where the default store writes, `config/masks.json` by default |
151
194
  | `after_sign_in` / `after_sign_out` | paths on this host |
152
195
  | `parent_controller` | what the engine's pages inherit, for your layout |
153
196
  | `authenticate_everything` | include `Authentication` on every controller |
154
197
  | `sign_out_of_issuer` | make every sign-out an RP-initiated logout |
155
198
  | `session_key` | the session key the tokens live under |
199
+ | `delegates` / `delegation_redirect_uri` | ask for delegation in the handshake, and where connecting an account returns |
156
200
 
157
201
  ## Any Ruby app
158
202
 
159
- Everything above is `Masks::Client` underneath, and it is usable directly — the engine
160
- holds no protocol of its own.
203
+ The Rails engine is built on `Masks::Client`, which any Ruby app can use directly.
161
204
 
162
205
  ### The consumer half
163
206
 
@@ -169,22 +212,22 @@ session = Masks::Client::Session.new(
169
212
  )
170
213
 
171
214
  started = session.start(resource: "https://app.example.com/mcp")
172
- # hold started[:state], started[:nonce] and started[:verifier]; send the
173
- # browser to started[:url]
174
215
 
175
216
  tokens = session.complete(code: params[:code], verifier: held[:verifier])
176
217
  identity = session.identity(tokens)
177
218
  ```
178
219
 
179
- `start` sends a nonce only when `openid` was asked for, so holding one means an id token
180
- is owed, and the check on the way back is exact.
220
+ Hold `started[:state]`, `started[:nonce]`, and `started[:verifier]` in the session, and send the
221
+ browser to `started[:url]`. `start` sends a nonce only when the scope includes `openid`. Check the
222
+ returned `state` yourself, and compare `identity["nonce"]` with the one you held.
181
223
 
182
- Also: `refresh`, `exchange`, `revoke`, `introspect`, `userinfo`, and `end_session_url`.
224
+ `Session` also has `refresh`, `exchange`, `client_credentials`, `revoke`, `introspect`, `userinfo`,
225
+ and `end_session_url`. It authenticates with `client_secret`, or with `private_key` and `key_id` for
226
+ `private_key_jwt`.
183
227
 
184
- Discovery and JWKS are cached for five minutes, with invalidate-and-refetch on an unknown
185
- `kid`, so a key rotation is picked up without a restart. The cache is real only if the
186
- `Issuer` is: use `Masks::Client::Issuer.resolve`, or the registry behind it, rather than
187
- constructing one per request.
228
+ Discovery and JWKS are cached for five minutes and refetched on an unknown `kid`, so a key rotation
229
+ needs no restart. The cache lives on the `Issuer`, so use `Masks::Client::Issuer.resolve` instead
230
+ of constructing one per request.
188
231
 
189
232
  ### The handshake
190
233
 
@@ -197,12 +240,13 @@ handshake = Masks::Client::Handshake.new(
197
240
  return_to: "https://app.example.com/"
198
241
  )
199
242
 
200
- started = handshake.start # hold started[:state]; send the browser on
243
+ started = handshake.start
201
244
  registration = handshake.complete(params, state: held)
202
245
  ```
203
246
 
204
- `complete` refuses an `error`, a `state` that does not match this browser, and an `iss`
205
- that is not the issuer it asked — each **before** anything is redeemed.
247
+ Hold `started[:state]` and send the browser to `started[:url]`. Before it redeems anything,
248
+ `complete` refuses an `error`, a `state` that does not match this browser, and an `iss` other than
249
+ the issuer it asked.
206
250
 
207
251
  ### The resource-server half
208
252
 
@@ -213,24 +257,33 @@ resource = Masks::Client::Resource.new(
213
257
  scopes: { "uris:catalog:read" => "Search your catalog" }
214
258
  )
215
259
 
216
- claims = resource.authenticate(request.authorization, scope: "uris:catalog:read")
260
+ claims = resource.authenticate(
261
+ request.authorization, scope: "uris:catalog:read",
262
+ proof: request.get_header("HTTP_DPOP"), method: request.request_method, url: request.url
263
+ )
217
264
  claims.subject
218
265
  claims.tenant.subdomain
219
266
  ```
220
267
 
221
- `authenticate` answers `Claims` or raises. A refusal builds the RFC 6750 challenge
222
- carrying `resource_metadata` and the scopes it would have accepted:
268
+ `authenticate` answers `Claims` or raises. A token bound to a key with DPoP carries `cnf.jkt`, and is
269
+ accepted only as `Authorization: DPoP` with a proof signed by that key for this method, URL, and
270
+ token, made within the last minute and never seen before. `proof`, `method`, and `url` are what it
271
+ checks the proof against. Seen proofs are kept in the process's memory. A deployment with several
272
+ processes passes `replay:` to `Resource.new`, any object whose `first?(key, expires_in:)` answers true
273
+ only once for each key.
274
+
275
+ A refusal builds the RFC 6750 challenge carrying `resource_metadata` and the scopes it would have
276
+ accepted:
223
277
 
224
278
  ```ruby
225
279
  response.headers["WWW-Authenticate"] = resource.challenge(error)
226
280
  ```
227
281
 
228
- `resource.metadata` is the RFC 9728 document to serve at
229
- `/.well-known/oauth-protected-resource`. The `scope_descriptions` extension in it is how
230
- an auth server renders your scopes as sentences on its consent screen; it has no other
231
- way to know what `uris:catalog:read` means.
282
+ `resource.metadata` is the RFC 9728 document to serve at `/.well-known/oauth-protected-resource`.
283
+ Its `scope_descriptions` extension gives masks the sentences its consent screen shows for each
284
+ scope.
232
285
 
233
- There is a Rack middleware for consumers that want the challenge below the framework:
286
+ A Rack middleware does the same below the framework, proof included:
234
287
 
235
288
  ```ruby
236
289
  use Masks::Client::Rack, resource: resource, scope: "uris:catalog:read"
@@ -238,23 +291,21 @@ use Masks::Client::Rack, resource: resource, scope: "uris:catalog:read"
238
291
 
239
292
  ### Introspection
240
293
 
241
- A resource server doing JWT-only validation cannot see a revocation until the token
242
- expires. Ask the issuer instead:
294
+ A resource server that only verifies the JWT sees a revocation when the token expires. Introspection
295
+ asks the issuer:
243
296
 
244
297
  ```ruby
245
298
  found = session.introspect(token)
246
299
  found.active? && found.permits?("uris:catalog:read")
247
300
  ```
248
301
 
249
- `Introspection` is a `Claims` whose `permit!` raises when the issuer says the token is
250
- not active, so moving from local verification to introspection needs no extra boolean
251
- check.
302
+ `Introspection` is a `Claims` whose `permit!` also raises when the token is not active.
252
303
 
253
304
  ### Somebody else's account
254
305
 
255
- An app that acts as somebody at Google, Microsoft or an MCP server while they are away asks masks for
256
- a [delegation](https://masks.pages.dev/concepts/delegation/). masks keeps and refreshes the
257
- provider's tokens; the app keeps one secret per connection and trades it for a live access token when
306
+ An app that acts as somebody at Google, Microsoft, or an MCP server while they are away asks masks for
307
+ a [delegation](https://masks.pages.dev/guides/connecting-via-sdk/#delegation). masks keeps and refreshes the
308
+ provider's tokens. The app keeps one secret per connection and trades it for a live access token when
258
309
  the last one runs out.
259
310
 
260
311
  ```ruby
@@ -280,9 +331,9 @@ A Rails app sets `config.delegates = true`, so its handshake asks for `masks:del
280
331
  exchange with it.
281
332
 
282
333
  `upstream.secret` replaces the one you passed in, every time. `Delegations::Refused` means somebody
283
- has to connect again; `Delegations::Unavailable` is worth retrying. Both carry `secret` when masks had
284
- already rotated it, so keep it. Spend a secret from one place at a time: spending one twice revokes
285
- it.
334
+ has to connect again, and `Delegations::Unavailable` is worth retrying. Both carry `secret` when masks
335
+ had already rotated it, so keep it. Spend a secret from one place at a time, because spending one
336
+ twice revokes it.
286
337
 
287
338
  Tests use the fake, which needs no issuer:
288
339
 
@@ -298,16 +349,10 @@ fake.revoke(held.connection)
298
349
  fake.unavailable(held.connection)
299
350
  ```
300
351
 
301
- ## Which issuers this speaks to
302
-
303
- masks publishes `masks_protocol_version` in its discovery document, and this gem needs at
304
- least version 1 — the one that serves `handshake_endpoint` and the approval flow behind
305
- it. An older issuer is refused at configuration time, with a sentence saying so.
306
-
307
352
  ## Documentation
308
353
 
309
- This README is the reference for the gem. [masks.pages.dev](https://masks.pages.dev) carries what is
310
- generated from the server's own code — the `/manage` GraphQL schema, and the design system.
354
+ The full reference is generated from this gem at [masks.pages.dev](https://masks.pages.dev): `ruby`
355
+ for `Masks::Client` and `rails` for the engine.
311
356
 
312
357
  ## License
313
358
 
@@ -2,6 +2,7 @@ module Masks
2
2
  module Rails
3
3
  class HandshakesController < BaseController
4
4
  before_action :require_unconfigured_or_signed_in
5
+ before_action :require_unconfigured_or_manager
5
6
 
6
7
  def show
7
8
  return begin! unless masks_config.configured?(request)
@@ -69,6 +70,16 @@ module Masks
69
70
  redirect_to masks_config.after_sign_out
70
71
  end
71
72
 
73
+ def require_unconfigured_or_manager
74
+ return unless masks_config.configured?(request)
75
+ return if masks_config.manages?(request, masks_identity)
76
+
77
+ @code = "forbidden"
78
+ @description = "only somebody this app lets manage its connection can reconnect or disconnect it"
79
+
80
+ render :refused, status: :forbidden
81
+ end
82
+
72
83
  def stale
73
84
  @code = "invalid_state"
74
85
  @description = "that connection request is not one this browser started, or it expired"
@@ -1,6 +1,9 @@
1
1
  module Masks
2
2
  module Rails
3
3
  class SessionsController < BaseController
4
+ RESTARTED = "masks_restarted_at".freeze
5
+ RESTART_WITHIN = 60
6
+
4
7
  def show
5
8
  if masks_configured? && (masks_signed_in? || (masks_tokens && masks_refresh!))
6
9
  response.headers["Cache-Control"] = "no-store"
@@ -21,7 +24,9 @@ module Masks
21
24
  )
22
25
  started = masks_session.start(
23
26
  state: pending.id,
24
- resource: masks_config.resource_for(request)
27
+ resource: masks_config.resource_for(request),
28
+ organization: Masks::Client::Session.organization_key(params[:organization]) ||
29
+ masks_config.organization_for(request)
25
30
  )
26
31
 
27
32
  masks_requests.amend(
@@ -34,8 +39,8 @@ module Masks
34
39
  def callback
35
40
  pending = masks_requests.claim(params[:state])
36
41
 
37
- return stale if pending.nil?
38
42
  return refuse(params[:error], params[:error_description], pending) if params[:error].present?
43
+ return stale if pending.nil?
39
44
 
40
45
  tokens = masks_session.complete(
41
46
  code: params[:code],
@@ -53,6 +58,7 @@ module Masks
53
58
  return refuse("invalid_nonce", "the id token was issued for another request", pending)
54
59
  end
55
60
 
61
+ renew_session
56
62
  masks_store(tokens, identity: identity)
57
63
 
58
64
  redirect_to pending[:return_to] || masks_config.after_sign_in
@@ -68,7 +74,9 @@ module Masks
68
74
  everywhere = masks_config.sign_out_of_issuer || params[:everywhere].present?
69
75
  upstream = everywhere ? masks_logout_url : nil
70
76
 
77
+ revoke_refresh_token
71
78
  masks_forget
79
+ reset_session
72
80
 
73
81
  if masks_wants_json?
74
82
  render json: { "signed_in" => false, "logout_url" => upstream }.compact
@@ -79,6 +87,23 @@ module Masks
79
87
 
80
88
  private
81
89
 
90
+ def renew_session
91
+ carried = session.to_hash.slice(REQUESTS, HANDSHAKES)
92
+
93
+ reset_session
94
+ carried.each { |key, value| session[key] = value }
95
+ end
96
+
97
+ def revoke_refresh_token
98
+ held = masks_tokens&.refresh_token
99
+
100
+ return if held.nil? || !masks_configured?
101
+
102
+ masks_session.revoke(held, hint: "refresh_token")
103
+ rescue Masks::Client::Error
104
+ false
105
+ end
106
+
82
107
  def requested_return_to
83
108
  masks_local_path(params[:return_to])
84
109
  end
@@ -93,6 +118,9 @@ module Masks
93
118
  end
94
119
 
95
120
  def stale
121
+ return redirect_to(masks_config.after_sign_in) if masks_signed_in? || (masks_tokens && masks_refresh!)
122
+ return restart unless masks_wants_json? || restarted_lately?
123
+
96
124
  answer(
97
125
  "invalid_state",
98
126
  "that sign-in request is not one this browser started, or it expired",
@@ -100,6 +128,15 @@ module Masks
100
128
  )
101
129
  end
102
130
 
131
+ def restart
132
+ session[RESTARTED] = Time.now.to_i
133
+ redirect_to start_path
134
+ end
135
+
136
+ def restarted_lately?
137
+ Time.now.to_i - session[RESTARTED].to_i < RESTART_WITHIN
138
+ end
139
+
103
140
  def refuse(code, description, pending = nil)
104
141
  masks_forget if pending
105
142