masks 0.10.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: c0b13e1f370033e4c4512becb39b4d96e4b9d28b39b7f1e773592138626d1151
4
- data.tar.gz: 8540eeecb1b448047198d5da57e5978d0e4ec249138334e7c39d41a719854a99
3
+ metadata.gz: 22811283549c975e2550ab8546760831275db1efa68ddfd16ebe1dcfb96278a5
4
+ data.tar.gz: 888decf6ea332f751cb446a4e7535313527141ed64086cbf62acc2787d320a9b
5
5
  SHA512:
6
- metadata.gz: fc1b4981446fdc5282e21c00c93a63cbc124d3c675512172a849cdd698f0a52839fce8098fdde6443814131f198eef5829d1dd52416e02bb51ef95f7d5fb634a
7
- data.tar.gz: e40f6519f1c22fefa134217cff31701617551feed13e374f1d801307c2f021cb21eba5b85adc4eb9d28e133e855c4aad4cc05caa4749d34fadd9d7e833493cc1
6
+ metadata.gz: 33275896c9010d94818885f7a0a3f71b83f6782af7384cf29fb70d482b03492b8e662f27d6f748e1d9a05f453f5294dc910ca5d1492395c7bc3e0bc57aaaea6f
7
+ data.tar.gz: 5fb2c1d80a01a0723c3533bc8861cca4c2d0761e9ef5d4ef21f46de41d611835bf9fefe28ac0bab860b65bb0ee166cf8288fbda1a178644f58dc3a850edaea96
data/CHANGELOG.md CHANGED
@@ -1,5 +1,68 @@
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
+
36
+ ## [0.11.0](https://github.com/masksrb/masks/compare/gem/v0.10.0...gem/v0.11.0) (2026-10-13)
37
+
38
+
39
+ ### Features
40
+
41
+ * **client:** a session holds its tokens to a DPoP key and proves it on every token request ([be3732c](https://github.com/masksrb/masks/commit/be3732cec2873fed75d8c70591f9bb1a2dcfb93c))
42
+ * **client:** a session runs the device flow and polls until somebody approves the code ([cd96836](https://github.com/masksrb/masks/commit/cd96836a9d97049f80b9efcf6eec8335e7650a6a))
43
+ * **client:** config.replay names one store of seen DPoP proofs for every process of a Rails app ([e3baf5a](https://github.com/masksrb/masks/commit/e3baf5a04b652ec2205bbc88004cb37b78ac9b0e))
44
+ * **client:** Masks::Client.embed answers an issuer's requests inside the process, for an app that mounts masks and signs in through it ([7473031](https://github.com/masksrb/masks/commit/74730318cd335a74207b1fea0156b646e61d74c1))
45
+ * **client:** Session#enroll asks masks for an enrollment token that a machine spends once to register its own key ([be52734](https://github.com/masksrb/masks/commit/be52734f7d470140749bf2f2d208d81ca85bc42d))
46
+ * **client:** Session#unenroll archives a machine the app enrolled and revokes its tokens ([2743890](https://github.com/masksrb/masks/commit/2743890063f1e3818f8acb29048bb549f1b94220))
47
+ * **server:** /manage, the sign-in pages, and the docs share one palette and one font stack ([17efe87](https://github.com/masksrb/masks/commit/17efe87c81c14d7541a37292bf58ba242836eca5))
48
+
49
+
50
+ ### Fixes
51
+
52
+ * **client:** signing out everywhere returns through the handshake callback masks registered, so masks no longer refuses it ([6eb6e70](https://github.com/masksrb/masks/commit/6eb6e7067d43803f0ac8dcfbc04e2d8057ac2ca0))
53
+ * **client:** the gem speaks only https off loopback, reads at most 1 MB, and answers 503 when its replay store is down ([6c03a43](https://github.com/masksrb/masks/commit/6c03a430a500d3476e018cf650bf093ad2aca102))
54
+
55
+
56
+ ### Documentation
57
+
58
+ * each language has a group under Reference, with an overview, a page per framework, and its API reference ([64b7837](https://github.com/masksrb/masks/commit/64b7837e0484d11ddd5b4d926fc4aa800c06ed81))
59
+ * each SDK keeps a TODO of the cleanups, security follow-ups, and testing left from the framework integrations ([4c4353b](https://github.com/masksrb/masks/commit/4c4353b63713700034c78f3d3c0b3b45ac7b50b3))
60
+
61
+
62
+ ### Refactoring
63
+
64
+ * **server:** /manage derives its surfaces from the shared palette, and --label folds into --brass ([c00d32b](https://github.com/masksrb/masks/commit/c00d32b821cc99b8cbc2903a7a4c0c08aa2770bd))
65
+
3
66
  ## [0.10.0](https://github.com/masksrb/masks/compare/gem/v0.9.2...gem/v0.10.0) (2026-10-11)
4
67
 
5
68
 
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/guides/rails/) walks through the rest.
26
-
27
- ### The handshake
28
-
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.
19
+ The gem needs Ruby 3.2 or later.
34
20
 
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:
21
+ ## Rails
38
22
 
39
- ```ruby
40
- config.manages = :admin
41
- config.manages = ->(request, masked) { masked.subject == ENV["OWNER_SUB"] }
42
- ```
23
+ 1. Run the install generator:
43
24
 
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:
25
+ ```sh
26
+ bin/rails generate masks:install --resource https://app.example.com
27
+ ```
46
28
 
47
- ```ruby
48
- config.credentials = ->(request) { Tenant.for(request).masks_credentials }
49
- config.store = ->(request, registration) { Tenant.for(request).connect!(registration) }
50
- ```
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.
51
32
 
52
- ### Signing in
33
+ Then require a sign-in in a controller:
53
34
 
54
35
  ```ruby
55
36
  class DashboardController < ApplicationController
@@ -58,180 +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/guides/rails/#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/browser/) 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
-
229
47
  ## Any Ruby app
230
48
 
231
- The Rails engine is built on `Masks::Client`, which any Ruby app can use directly.
232
-
233
- ### The consumer half
234
-
235
49
  ```ruby
236
50
  session = Masks::Client::Session.new(
237
51
  issuer: "https://demo.auth.example.com",
@@ -239,148 +53,21 @@ session = Masks::Client::Session.new(
239
53
  redirect_uri: "https://app.example.com/auth/callback"
240
54
  )
241
55
 
242
- started = session.start(resource: "https://app.example.com/mcp")
243
-
244
- tokens = session.complete(code: params[:code], verifier: held[:verifier])
56
+ started = session.start
57
+ tokens = session.complete(code: params[:code], verifier: started[:verifier])
245
58
  identity = session.identity(tokens)
246
59
  ```
247
60
 
248
- Hold `started[:state]`, `started[:nonce]`, and `started[:verifier]` in the session, and send the
249
- browser to `started[:url]`. `start` sends a nonce only when the scope includes `openid`. Check the
250
- returned `state` yourself, and compare `identity["nonce"]` with the one you held.
251
-
252
- `Session` also has `refresh`, `exchange`, `client_credentials`, `revoke`, `introspect`, `userinfo`,
253
- and `end_session_url`. It authenticates with `client_secret`, or with `private_key` and `key_id` for
254
- `private_key_jwt`.
255
-
256
- Discovery and JWKS are cached for five minutes and refetched on an unknown `kid`, so a key rotation
257
- needs no restart. The cache lives on the `Issuer`, so use `Masks::Client::Issuer.resolve` instead
258
- of constructing one per request.
259
-
260
- ### The handshake
261
-
262
- The flow that connects a first-party app:
263
-
264
- ```ruby
265
- handshake = Masks::Client::Handshake.new(
266
- issuer, name: "xixo", resource: "https://app.example.com/mcp",
267
- redirect_uris: [ "https://app.example.com/auth/callback" ],
268
- return_to: "https://app.example.com/"
269
- )
270
-
271
- started = handshake.start
272
- registration = handshake.complete(params, state: held)
273
- ```
274
-
275
- Hold `started[:state]` and send the browser to `started[:url]`. Before it redeems anything,
276
- `complete` refuses an `error`, a `state` that does not match this browser, and an `iss` other than
277
- the issuer it asked.
278
-
279
- ### The resource-server half
280
-
281
- ```ruby
282
- resource = Masks::Client::Resource.new(
283
- issuer: "https://demo.auth.example.com",
284
- url: "https://app.example.com/mcp",
285
- scopes: { "xixo:catalog:read" => "Search your catalog" }
286
- )
287
-
288
- claims = resource.authenticate(
289
- request.authorization, scope: "xixo:catalog:read",
290
- proof: request.get_header("HTTP_DPOP"), method: request.request_method, url: request.url
291
- )
292
- claims.subject
293
- claims.tenant.subdomain
294
- ```
295
-
296
- `authenticate` answers `Claims` or raises. A token bound to a key with DPoP carries `cnf.jkt`, and is
297
- accepted only as `Authorization: DPoP` with a proof signed by that key for this method, URL, and
298
- token, made within the last minute and never seen before. `proof`, `method`, and `url` are what it
299
- checks the proof against. Seen proofs are kept in the process's memory. A deployment with several
300
- processes passes `replay:` to `Resource.new`, any object whose `first?(key, expires_in:)` answers true
301
- only once for each key.
302
-
303
- A refusal builds the RFC 6750 challenge carrying `resource_metadata` and the scopes it would have
304
- accepted:
305
-
306
- ```ruby
307
- response.headers["WWW-Authenticate"] = resource.challenge(error)
308
- ```
309
-
310
- `resource.metadata` is the RFC 9728 document to serve at `/.well-known/oauth-protected-resource`.
311
- Its `scope_descriptions` extension gives masks the sentences its consent screen shows for each
312
- scope.
313
-
314
- A Rack middleware does the same below the framework, proof included:
315
-
316
- ```ruby
317
- use Masks::Client::Rack, resource: resource, scope: "xixo:catalog:read"
318
- ```
319
-
320
- ### Introspection
321
-
322
- A resource server that only verifies the JWT sees a revocation when the token expires. Introspection
323
- asks the issuer:
324
-
325
- ```ruby
326
- found = session.introspect(token)
327
- found.active? && found.permits?("xixo:catalog:read")
328
- ```
329
-
330
- `Introspection` is a `Claims` whose `permit!` also raises when the token is not active.
331
-
332
- ### Somebody else's account
333
-
334
- An app that acts as somebody at Google, Microsoft, or an MCP server while they are away asks masks for
335
- a [delegation](https://masks.pages.dev/guides/connecting-via-sdk/#delegation). masks keeps and refreshes the
336
- provider's tokens. The app keeps one secret per connection and trades it for a live access token when
337
- the last one runs out.
338
-
339
- ```ruby
340
- delegations = Masks::Client.delegations(
341
- ENV["MASKS_ISSUER"], client_id: id, client_secret: secret, redirect_uri: "https://app.test/connect/callback"
342
- )
343
-
344
- started = delegations.start(provider: "google")
345
- session[:connecting] = started
346
- redirect_to started["url"]
347
-
348
- held = delegations.finish(params: params, started: session.delete(:connecting))
349
- held.connection
350
- held.secret
351
-
352
- upstream = delegations.token(held.secret, connection: held.connection)
353
- upstream.access_token
354
- upstream.expires_at
355
- upstream.secret
356
- ```
357
-
358
- A Rails app sets `config.delegates = true`, so its handshake asks for `masks:delegate:` and the token
359
- exchange with it.
360
-
361
- `upstream.secret` replaces the one you passed in, every time. `Delegations::Refused` means somebody
362
- has to connect again, and `Delegations::Unavailable` is worth retrying. Both carry `secret` when masks
363
- had already rotated it, so keep it. Spend a secret from one place at a time, because spending one
364
- twice revokes it.
365
-
366
- Tests use the fake, which needs no issuer:
367
-
368
- ```ruby
369
- require "masks/client/delegations/fake"
370
-
371
- fake = Masks::Client::Delegations::Fake.new
372
- started = fake.start(provider: "notion")
373
- held = fake.finish(params: fake.approve(started), started: started)
374
-
375
- fake.token(held.secret, connection: held.connection)
376
- fake.revoke(held.connection)
377
- fake.unavailable(held.connection)
378
- ```
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.
379
64
 
380
65
  ## Documentation
381
66
 
382
- The full reference is generated from this gem at [masks.pages.dev](https://masks.pages.dev): `ruby`
383
- 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.
384
71
 
385
72
  ## License
386
73
 
@@ -29,7 +29,7 @@ module Masks
29
29
  end
30
30
 
31
31
  def destroy
32
- 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?
33
33
  return redirect_to(masks_handshake_path) unless masks_config.can_forget?
34
34
 
35
35
  forget_upstream
@@ -67,7 +67,7 @@ module Masks
67
67
  return unless masks_config.configured?(request)
68
68
  return if masks_signed_in?
69
69
 
70
- redirect_to masks_config.after_sign_out
70
+ redirect_to masks_after_sign_out_path
71
71
  end
72
72
 
73
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
@@ -3,6 +3,9 @@ module Masks
3
3
  class SessionsController < BaseController
4
4
  RESTARTED = "masks_restarted_at".freeze
5
5
  RESTART_WITHIN = 60
6
+ SAME_ORIGIN = %w[same-origin none].freeze
7
+
8
+ before_action :refuse_cross_site, only: :destroy
6
9
 
7
10
  def show
8
11
  if masks_configured? && (masks_signed_in? || (masks_tokens && masks_refresh!))
@@ -10,7 +13,7 @@ module Masks
10
13
 
11
14
  render json: masked.to_h
12
15
  else
13
- masks_forget
16
+ masks_forget unless masks_configured?
14
17
  masks_refuse_json
15
18
  end
16
19
  end
@@ -40,7 +43,8 @@ module Masks
40
43
  def callback
41
44
  pending = masks_requests.claim(params[:state])
42
45
 
43
- return refuse(params[:error], params[:error_description], pending) if params[:error].present?
46
+ return refuse(params[:error], params[:error_description], pending) if params[:error].present? && pending
47
+ return unstarted if params[:error].present?
44
48
  return stale if pending.nil?
45
49
 
46
50
  tokens = masks_session.complete(
@@ -73,24 +77,52 @@ module Masks
73
77
 
74
78
  def destroy
75
79
  everywhere = masks_config.sign_out_of_issuer || params[:everywhere].present?
76
- upstream = everywhere ? masks_logout_url : nil
77
80
 
78
81
  revoke_refresh_token
79
82
  masks_forget
80
83
  reset_session
81
84
 
85
+ upstream = everywhere ? masks_sign_out_upstream : nil
86
+
82
87
  if masks_wants_json?
83
88
  render json: { "signed_in" => false, "logout_url" => upstream }.compact
84
89
  else
85
- redirect_to upstream || masks_config.after_sign_out, allow_other_host: upstream.present?
90
+ redirect_to upstream || masks_after_sign_out_path, allow_other_host: upstream.present?
86
91
  end
87
92
  end
88
93
 
94
+ def signed_out
95
+ return stale_sign_out if masks_farewells.claim(params[:state].to_s).nil?
96
+
97
+ redirect_to masks_after_sign_out_path
98
+ end
99
+
89
100
  private
90
101
 
102
+ def refuse_cross_site
103
+ return if same_origin?
104
+
105
+ answer("invalid_request", "that request came from another site", :forbidden, failed: "sign-out")
106
+ end
107
+
108
+ def same_origin?
109
+ site = request.headers["Sec-Fetch-Site"].to_s
110
+
111
+ return false unless site.empty? || SAME_ORIGIN.include?(site)
112
+
113
+ sent = request.headers["Origin"].to_s
114
+
115
+ sent.empty? || [ request.base_url, own_origin ].include?(sent)
116
+ end
117
+
118
+ def own_origin
119
+ URI.join(masks_config.redirect_uri_for(request), "/").to_s.chomp("/")
120
+ end
121
+
91
122
  def renew_session
92
123
  carried = session.to_hash.slice(REQUESTS, HANDSHAKES, Masks::Rails::Authentication::STEPPED_UP)
93
124
 
125
+ masks_forget
94
126
  reset_session
95
127
  carried.each { |key, value| session[key] = value }
96
128
  end
@@ -129,6 +161,16 @@ module Masks
129
161
  )
130
162
  end
131
163
 
164
+ def unstarted
165
+ answer("invalid_state", "masks refused a sign-in that this browser did not start, or one that expired",
166
+ :bad_request)
167
+ end
168
+
169
+ def stale_sign_out
170
+ answer("invalid_state", "that sign-out is not one this browser started, or it expired", :bad_request,
171
+ failed: "sign-out")
172
+ end
173
+
132
174
  def restart
133
175
  session[RESTARTED] = Time.now.to_i
134
176
  redirect_to start_path
@@ -144,11 +186,11 @@ module Masks
144
186
  answer(code, description, :bad_request)
145
187
  end
146
188
 
147
- def answer(code, description, status)
189
+ def answer(code, description, status, failed: "sign-in")
148
190
  if masks_wants_json?
149
191
  render json: { "error" => code, "error_description" => description }, status: status
150
192
  else
151
- render plain: "sign-in failed — #{code}: #{description}", status: status
193
+ render plain: "#{failed} failed — #{code}: #{description}", status: status
152
194
  end
153
195
  end
154
196
  end
data/config/routes.rb CHANGED
@@ -4,6 +4,7 @@ Masks::Rails::Engine.routes.draw do
4
4
  get "/avatar", to: "avatars#show", as: :avatar
5
5
  get "/callback", to: "sessions#callback", as: :callback
6
6
  match "/logout", to: "sessions#destroy", via: %i[get post delete], as: :logout
7
+ get "/logout/return", to: "sessions#signed_out", as: :logout_return
7
8
  post "/logout/backchannel", to: "logouts#create", as: :backchannel_logout
8
9
 
9
10
  get "/handshake", to: "handshakes#show", as: :handshake