masks 0.8.0 → 0.9.2

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: 3ec26aa454cf249dd26ce7e15444b43890e4e75954968c76cba3297b63f9819e
4
- data.tar.gz: 19609ef3a180ab7d6c675cac13cef843adb3ffc742d83b7fd64d7120bbcc93b5
3
+ metadata.gz: a915522b23f370321d7eca71a6d4965804d6988e441f6f019b1b1817bab7f0c3
4
+ data.tar.gz: 05f68f68ed521f6d378393cadda81bec7b984802f10ab651a5eaa8c9b31d4a7c
5
5
  SHA512:
6
- metadata.gz: 4a02a532dc018b73c27b350da38335b5ec27c5665d8b498564fd11b220e386919df8ecc9a24b80fb62f21d193c7a66166e99d2dc41c86fef3cb515f5aacc2b0d
7
- data.tar.gz: fbbb524a91dda55fc6ccf631d1ef8b270b9ff9835b894855dcd0dd07a054fcfb380c6fdea75930b93950909ba1e3618940215418c9fca7f0a5f32e30b33c96b4
6
+ metadata.gz: 13467dcc61e51edbb9a7493feb38ccb2cf0f1f42db9a127cb37ccb89c0bf3187de211f928bce1dd854d711f3418a0b504bb3a2b0e645ee8ac7d864b47fd8d4c5
7
+ data.tar.gz: 4b0b6abea05f55b54b9280de48c8096a8406fdd82e7dcb575584b56679ccb91fc3af11c2766cda26cee44ae8ace19f384a5714a0e3a81b029bcead957d8ba008
data/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.2](https://github.com/masksrb/masks/compare/gem/v0.9.1...gem/v0.9.2) (2026-10-10)
4
+
5
+
6
+ ### Documentation
7
+
8
+ * the examples name xixo, the resource server once called uris ([ca80f20](https://github.com/masksrb/masks/commit/ca80f20c9aea4ee6a3e7cd6c76c69425e4c800e3))
9
+
10
+ ## [0.9.1](https://github.com/masksrb/masks/compare/gem/v0.9.0...gem/v0.9.1) (2026-10-04)
11
+
12
+
13
+ ### Fixes
14
+
15
+ * **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))
16
+
17
+ ## [0.9.0](https://github.com/masksrb/masks/compare/gem/v0.8.0...gem/v0.9.0) (2026-10-04)
18
+
19
+
20
+ ### ⚠ BREAKING CHANGES
21
+
22
+ * **client:** a connected app refuses reconnecting and disconnecting until config.manages is set, for example config.manages = ->(request, identity) { identity["sub"] == ENV["OWNER_SUB"] }.
23
+
24
+ ### Fixes
25
+
26
+ * **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))
27
+ * **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))
28
+ * **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))
29
+ * **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))
30
+ * **client:** only somebody config.manages approves can reconnect or disconnect a connected app ([5665129](https://github.com/masksrb/masks/commit/566512979dc00a36148de57b4ea9954fc68db10b))
31
+
32
+
33
+ ### Documentation
34
+
35
+ * every guide, the reference prose, and the READMEs are checked against the code, shortened, and corrected ([94782d1](https://github.com/masksrb/masks/commit/94782d1fbfe363e90c40f0c728a241de9738e138))
36
+ * 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))
37
+
38
+
39
+ ### Refactoring
40
+
41
+ * **client:** the issuer version check nothing called, Registry#urls and Verifier#tenant go ([6b8cf05](https://github.com/masksrb/masks/commit/6b8cf0550b96f6b1b3b415ff27f655f90614bdfe))
42
+ * **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))
43
+ * **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))
44
+
3
45
  ## [0.8.0](https://github.com/masksrb/masks/compare/gem/v0.7.0...gem/v0.8.0) (2026-09-28)
4
46
 
5
47
 
data/README.md CHANGED
@@ -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,22 +91,25 @@ 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: "xixo: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).
110
107
 
111
108
  ### Organizations
112
109
 
113
110
  A person signs in to an app as a member of one organization when the app asks for the
114
111
  `organization` scope. Someone in several organizations picks one, and an app can pick for them by
115
- naming it: `/auth?organization=acme`, or `config.organization` for every sign-in.
112
+ naming it, with `/auth?organization=acme` or with `config.organization` for every sign-in.
116
113
 
117
114
  ```ruby
118
115
  Masks::Rails.configure do |config|
@@ -147,38 +144,37 @@ in to none and `insufficient_role` when they hold another role. A resource serve
147
144
  token:
148
145
 
149
146
  ```ruby
150
- masks_protect! scope: "uris:catalog:write", role: "owner", organization: "acme"
147
+ masks_protect! scope: "xixo:catalog:write", role: "owner", organization: "acme"
151
148
  ```
152
149
 
153
150
  Naming an organization without asking for the scope still holds the sign-in to its members and its
154
- sign-in policy; the app just learns nothing about the role.
151
+ sign-in policy, and the app learns nothing about the role.
155
152
 
156
153
  ### Avatars
157
154
 
158
- Every actor has three faces at once — an uploaded `photo`, an `identicon`, and two-letter
159
- `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.
160
157
 
161
158
  ```erb
162
159
  <img src="<%= masks_claims.avatars.identicon %>?size=64" width="64" height="64" alt="">
163
160
  ```
164
161
 
165
- A photo is a likeness of a person and needs a token, which an `<img>` cannot carry, so the engine
166
- 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:
167
164
 
168
165
  ```erb
169
166
  <img src="/auth/avatar" width="64" height="64" alt="">
170
167
  ```
171
168
 
172
- `masks_claims.picture` resolves the standard OIDC claim — an offsite `picture_url` if the actor set
173
- one, then the photo, then the identicon. `issuer.avatar_url(sub, style:, size:)` builds a URL for a
174
- 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.
175
172
 
176
173
  ### Signing out
177
174
 
178
- `DELETE /auth/logout` ends this app's session. Add `?everywhere=1` and the response
179
- carries `logout_url` — the issuer's end-session endpoint — so signing out can mean
180
- signing out. `config.sign_out_of_issuer = true` makes that the default for every
181
- 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.
182
178
 
183
179
  ### Configuration
184
180
 
@@ -188,22 +184,23 @@ a subdomain-per-tenant host needs.
188
184
  | | |
189
185
  |---|---|
190
186
  | `issuer` | the masks issuer this app signs in against |
191
- | `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 |
192
188
  | `resource_scopes` | what it accepts, published in its RFC 9728 metadata |
193
- | `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 |
194
190
  | `organization` | the organization every sign-in names, by key |
195
191
  | `credentials` / `store` | where the handshake's result lives |
196
- | `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 |
197
194
  | `after_sign_in` / `after_sign_out` | paths on this host |
198
195
  | `parent_controller` | what the engine's pages inherit, for your layout |
199
196
  | `authenticate_everything` | include `Authentication` on every controller |
200
197
  | `sign_out_of_issuer` | make every sign-out an RP-initiated logout |
201
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 |
202
200
 
203
201
  ## Any Ruby app
204
202
 
205
- Everything above is `Masks::Client` underneath, and it is usable directly — the engine
206
- holds no protocol of its own.
203
+ The Rails engine is built on `Masks::Client`, which any Ruby app can use directly.
207
204
 
208
205
  ### The consumer half
209
206
 
@@ -215,22 +212,22 @@ session = Masks::Client::Session.new(
215
212
  )
216
213
 
217
214
  started = session.start(resource: "https://app.example.com/mcp")
218
- # hold started[:state], started[:nonce] and started[:verifier]; send the
219
- # browser to started[:url]
220
215
 
221
216
  tokens = session.complete(code: params[:code], verifier: held[:verifier])
222
217
  identity = session.identity(tokens)
223
218
  ```
224
219
 
225
- `start` sends a nonce only when `openid` was asked for, so holding one means an id token
226
- 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.
227
223
 
228
- 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`.
229
227
 
230
- Discovery and JWKS are cached for five minutes, with invalidate-and-refetch on an unknown
231
- `kid`, so a key rotation is picked up without a restart. The cache is real only if the
232
- `Issuer` is: use `Masks::Client::Issuer.resolve`, or the registry behind it, rather than
233
- 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.
234
231
 
235
232
  ### The handshake
236
233
 
@@ -238,17 +235,18 @@ The flow that connects a first-party app:
238
235
 
239
236
  ```ruby
240
237
  handshake = Masks::Client::Handshake.new(
241
- issuer, name: "uris", resource: "https://app.example.com/mcp",
238
+ issuer, name: "xixo", resource: "https://app.example.com/mcp",
242
239
  redirect_uris: [ "https://app.example.com/auth/callback" ],
243
240
  return_to: "https://app.example.com/"
244
241
  )
245
242
 
246
- started = handshake.start # hold started[:state]; send the browser on
243
+ started = handshake.start
247
244
  registration = handshake.complete(params, state: held)
248
245
  ```
249
246
 
250
- `complete` refuses an `error`, a `state` that does not match this browser, and an `iss`
251
- 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.
252
250
 
253
251
  ### The resource-server half
254
252
 
@@ -256,51 +254,58 @@ that is not the issuer it asked — each **before** anything is redeemed.
256
254
  resource = Masks::Client::Resource.new(
257
255
  issuer: "https://demo.auth.example.com",
258
256
  url: "https://app.example.com/mcp",
259
- scopes: { "uris:catalog:read" => "Search your catalog" }
257
+ scopes: { "xixo:catalog:read" => "Search your catalog" }
260
258
  )
261
259
 
262
- claims = resource.authenticate(request.authorization, scope: "uris:catalog:read")
260
+ claims = resource.authenticate(
261
+ request.authorization, scope: "xixo:catalog:read",
262
+ proof: request.get_header("HTTP_DPOP"), method: request.request_method, url: request.url
263
+ )
263
264
  claims.subject
264
265
  claims.tenant.subdomain
265
266
  ```
266
267
 
267
- `authenticate` answers `Claims` or raises. A refusal builds the RFC 6750 challenge
268
- 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:
269
277
 
270
278
  ```ruby
271
279
  response.headers["WWW-Authenticate"] = resource.challenge(error)
272
280
  ```
273
281
 
274
- `resource.metadata` is the RFC 9728 document to serve at
275
- `/.well-known/oauth-protected-resource`. The `scope_descriptions` extension in it is how
276
- an auth server renders your scopes as sentences on its consent screen; it has no other
277
- 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.
278
285
 
279
- 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:
280
287
 
281
288
  ```ruby
282
- use Masks::Client::Rack, resource: resource, scope: "uris:catalog:read"
289
+ use Masks::Client::Rack, resource: resource, scope: "xixo:catalog:read"
283
290
  ```
284
291
 
285
292
  ### Introspection
286
293
 
287
- A resource server doing JWT-only validation cannot see a revocation until the token
288
- 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:
289
296
 
290
297
  ```ruby
291
298
  found = session.introspect(token)
292
- found.active? && found.permits?("uris:catalog:read")
299
+ found.active? && found.permits?("xixo:catalog:read")
293
300
  ```
294
301
 
295
- `Introspection` is a `Claims` whose `permit!` raises when the issuer says the token is
296
- not active, so moving from local verification to introspection needs no extra boolean
297
- check.
302
+ `Introspection` is a `Claims` whose `permit!` also raises when the token is not active.
298
303
 
299
304
  ### Somebody else's account
300
305
 
301
- An app that acts as somebody at Google, Microsoft or an MCP server while they are away asks masks for
302
- a [delegation](https://masks.pages.dev/concepts/delegation/). masks keeps and refreshes the
303
- 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
304
309
  the last one runs out.
305
310
 
306
311
  ```ruby
@@ -326,9 +331,9 @@ A Rails app sets `config.delegates = true`, so its handshake asks for `masks:del
326
331
  exchange with it.
327
332
 
328
333
  `upstream.secret` replaces the one you passed in, every time. `Delegations::Refused` means somebody
329
- has to connect again; `Delegations::Unavailable` is worth retrying. Both carry `secret` when masks had
330
- already rotated it, so keep it. Spend a secret from one place at a time: spending one twice revokes
331
- 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.
332
337
 
333
338
  Tests use the fake, which needs no issuer:
334
339
 
@@ -344,16 +349,10 @@ fake.revoke(held.connection)
344
349
  fake.unavailable(held.connection)
345
350
  ```
346
351
 
347
- ## Which issuers this speaks to
348
-
349
- masks publishes `masks_protocol_version` in its discovery document, and this gem needs at
350
- least version 1 — the one that serves `handshake_endpoint` and the approval flow behind
351
- it. An older issuer is refused at configuration time, with a sentence saying so.
352
-
353
352
  ## Documentation
354
353
 
355
- This README is the reference for the gem. [masks.pages.dev](https://masks.pages.dev) carries what is
356
- 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.
357
356
 
358
357
  ## License
359
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"
@@ -36,8 +39,8 @@ module Masks
36
39
  def callback
37
40
  pending = masks_requests.claim(params[:state])
38
41
 
39
- return stale if pending.nil?
40
42
  return refuse(params[:error], params[:error_description], pending) if params[:error].present?
43
+ return stale if pending.nil?
41
44
 
42
45
  tokens = masks_session.complete(
43
46
  code: params[:code],
@@ -55,6 +58,7 @@ module Masks
55
58
  return refuse("invalid_nonce", "the id token was issued for another request", pending)
56
59
  end
57
60
 
61
+ renew_session
58
62
  masks_store(tokens, identity: identity)
59
63
 
60
64
  redirect_to pending[:return_to] || masks_config.after_sign_in
@@ -70,7 +74,9 @@ module Masks
70
74
  everywhere = masks_config.sign_out_of_issuer || params[:everywhere].present?
71
75
  upstream = everywhere ? masks_logout_url : nil
72
76
 
77
+ revoke_refresh_token
73
78
  masks_forget
79
+ reset_session
74
80
 
75
81
  if masks_wants_json?
76
82
  render json: { "signed_in" => false, "logout_url" => upstream }.compact
@@ -81,6 +87,23 @@ module Masks
81
87
 
82
88
  private
83
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
+
84
107
  def requested_return_to
85
108
  masks_local_path(params[:return_to])
86
109
  end
@@ -95,6 +118,9 @@ module Masks
95
118
  end
96
119
 
97
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
+
98
124
  answer(
99
125
  "invalid_state",
100
126
  "that sign-in request is not one this browser started, or it expired",
@@ -102,6 +128,15 @@ module Masks
102
128
  )
103
129
  end
104
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
+
105
140
  def refuse(code, description, pending = nil)
106
141
  masks_forget if pending
107
142
 
@@ -29,13 +29,18 @@ module Masks
29
29
  class Challenge < Error
30
30
  attr_reader :code, :description, :status, :scope
31
31
 
32
- def initialize(code, description, status:, scope: nil)
32
+ def initialize(code, description, status:, scope: nil, dpop: false)
33
33
  super([ code, description ].compact.join(": "))
34
34
 
35
35
  @code = code
36
36
  @description = description
37
37
  @status = status
38
38
  @scope = scope
39
+ @dpop = dpop
40
+ end
41
+
42
+ def dpop?
43
+ @dpop
39
44
  end
40
45
  end
41
46
 
@@ -46,8 +51,8 @@ module Masks
46
51
  end
47
52
 
48
53
  class Unauthorized < Challenge
49
- def initialize(description, code: "invalid_token")
50
- super(code, description, status: 401)
54
+ def initialize(description, code: "invalid_token", dpop: false)
55
+ super(code, description, status: 401, dpop: dpop)
51
56
  end
52
57
  end
53
58
 
@@ -14,9 +14,7 @@ module Masks
14
14
  end
15
15
 
16
16
  def challenge
17
- Base64.urlsafe_encode64(
18
- OpenSSL::Digest::SHA256.digest(verifier), padding: false
19
- )
17
+ Proof.digest(verifier)
20
18
  end
21
19
 
22
20
  def method
@@ -0,0 +1,153 @@
1
+ module Masks
2
+ module Client
3
+ class Proof
4
+ class Invalid < Error; end
5
+
6
+ SCHEME = "DPoP".freeze
7
+ TYPE = "dpop+jwt".freeze
8
+ ALGORITHMS = %w[ES256 ES384 ES512 PS256 PS384 PS512 RS256].freeze
9
+ SECRET = %w[d p q dp dq qi k].freeze
10
+ THUMBED = { "EC" => %w[crv kty x y], "RSA" => %w[e kty n] }.freeze
11
+ LEEWAY = 30
12
+ WINDOW = 60
13
+ MEMORY = WINDOW + (LEEWAY * 2)
14
+
15
+ class Memory
16
+ def initialize
17
+ @held = {}
18
+ @lock = Mutex.new
19
+ @swept = 0.0
20
+ end
21
+
22
+ def first?(key, expires_in:)
23
+ now = Time.now.to_f
24
+
25
+ @lock.synchronize do
26
+ sweep(now) if now - @swept > 1
27
+ return false if @held.key?(key) && @held[key] >= now
28
+
29
+ @held[key] = now + expires_in
30
+ true
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def sweep(now)
37
+ @held.delete_if { |_, until_at| until_at < now }
38
+ @swept = now
39
+ end
40
+ end
41
+
42
+ MEMORY_LOCK = Mutex.new
43
+
44
+ def self.memory
45
+ MEMORY_LOCK.synchronize { @memory ||= Memory.new }
46
+ end
47
+
48
+ def self.thumbprint(jwk)
49
+ named = THUMBED[jwk["kty"]] || raise(Invalid, "the proof key is of a kind this resource does not read")
50
+ held = named.to_h { |field| [ field, jwk[field] ] }
51
+
52
+ raise Invalid, "the proof key is missing part of itself" if held.any? { |_, value| value.to_s.empty? }
53
+
54
+ digest(JSON.generate(held))
55
+ end
56
+
57
+ def self.digest(value)
58
+ Base64.urlsafe_encode64(OpenSSL::Digest::SHA256.digest(value.to_s), padding: false)
59
+ end
60
+
61
+ def initialize(proof, method:, url:, replay: nil)
62
+ held = proof.to_s.split(",").map(&:strip).reject(&:empty?)
63
+
64
+ raise Invalid, "exactly one DPoP proof is required" unless held.size == 1
65
+
66
+ @proof = held.first
67
+ @method = method.to_s.upcase
68
+ @url = url.to_s
69
+ @replay = replay
70
+ end
71
+
72
+ def check!(access_token:, jkt:)
73
+ header = keyed
74
+ claims = verified(header)
75
+ thumbprint = self.class.thumbprint(header["jwk"])
76
+
77
+ raise Invalid, "that proof was made with another key than the token is bound to" unless same?(thumbprint, jkt)
78
+
79
+ matches!(claims)
80
+ timely!(claims)
81
+ raise Invalid, "that proof was made for another token" unless same?(claims["ath"].to_s, self.class.digest(access_token))
82
+
83
+ once!(claims, thumbprint)
84
+
85
+ self
86
+ end
87
+
88
+ private
89
+
90
+ def keyed
91
+ header = JWT.decode(@proof, nil, false).last
92
+
93
+ raise Invalid, "a proof must be typed #{TYPE}" unless header["typ"].to_s.casecmp?(TYPE)
94
+ raise Invalid, "#{header['alg'] || 'that'} is not an algorithm a proof may use" unless ALGORITHMS.include?(header["alg"].to_s)
95
+
96
+ jwk = header["jwk"]
97
+
98
+ raise Invalid, "a proof must carry the public key that signed it" unless jwk.is_a?(Hash)
99
+ raise Invalid, "a proof key must be public" if jwk["kty"].to_s == "oct" || jwk.keys.any? { |field| SECRET.include?(field.to_s) }
100
+
101
+ header
102
+ rescue JWT::DecodeError
103
+ raise Invalid, "that proof is not a readable JWT"
104
+ end
105
+
106
+ def verified(header)
107
+ JWT.decode(@proof, JWT::JWK.new(header["jwk"]).verify_key, true,
108
+ algorithms: [ header["alg"].to_s ], verify_expiration: false).first
109
+ rescue JWT::DecodeError, JWT::JWKError, OpenSSL::PKey::PKeyError, ArgumentError
110
+ raise Invalid, "that proof was not signed by the key it carries"
111
+ end
112
+
113
+ def matches!(claims)
114
+ raise Invalid, "that proof was made for another method" unless claims["htm"].to_s.upcase == @method
115
+ raise Invalid, "that proof was made for another URL" unless tidied(claims["htu"]) == tidied(@url)
116
+ end
117
+
118
+ def tidied(value)
119
+ uri = URI.parse(value.to_s)
120
+ raise Invalid, "that proof names no URL" if uri.host.nil?
121
+
122
+ port = ":#{uri.port}" unless uri.port.nil? || uri.port == uri.default_port
123
+ path = uri.path.to_s == "/" ? "" : uri.path.to_s
124
+
125
+ "#{uri.scheme.to_s.downcase}://#{uri.host.downcase}#{port}#{path}"
126
+ rescue URI::InvalidURIError
127
+ raise Invalid, "that proof names a URL this resource cannot read"
128
+ end
129
+
130
+ def timely!(claims)
131
+ made = claims["iat"]
132
+
133
+ raise Invalid, "a proof must say when it was made" unless made.is_a?(Numeric)
134
+ raise Invalid, "that proof was made too long ago" if made < Time.now.to_i - WINDOW - LEEWAY
135
+ raise Invalid, "that proof was made in the future" if made > Time.now.to_i + LEEWAY
136
+ end
137
+
138
+ def once!(claims, thumbprint)
139
+ jti = claims["jti"].to_s
140
+
141
+ raise Invalid, "a proof must carry a jti" if jti.empty?
142
+ return if @replay.nil? || @replay.first?("#{thumbprint}:#{jti}", expires_in: MEMORY)
143
+
144
+ raise Invalid, "that proof has already been used"
145
+ end
146
+
147
+ def same?(held, expected)
148
+ !held.to_s.empty? && held.bytesize == expected.to_s.bytesize &&
149
+ OpenSSL.fixed_length_secure_compare(held, expected.to_s)
150
+ end
151
+ end
152
+ end
153
+ end
@@ -15,7 +15,10 @@ module Masks
15
15
  def call(env)
16
16
  return @app.call(env) unless guards?(env)
17
17
 
18
- env[CLAIMS] = resource(env).authenticate(env["HTTP_AUTHORIZATION"], scope: @scope)
18
+ env[CLAIMS] = resource(env).authenticate(
19
+ env["HTTP_AUTHORIZATION"], scope: @scope,
20
+ proof: env["HTTP_DPOP"], method: env["REQUEST_METHOD"], url: url(env)
21
+ )
19
22
 
20
23
  @app.call(env)
21
24
  rescue Challenge => e
@@ -32,6 +35,17 @@ module Masks
32
35
  @only.call(env)
33
36
  end
34
37
 
38
+ def url(env)
39
+ if defined?(::Rack::Request)
40
+ request = ::Rack::Request.new(env)
41
+ return "#{request.base_url}#{request.path}"
42
+ end
43
+
44
+ host = env["HTTP_HOST"] || "#{env['SERVER_NAME']}:#{env['SERVER_PORT']}"
45
+
46
+ "#{env['rack.url_scheme'] || 'http'}://#{host}#{env['SCRIPT_NAME']}#{env['PATH_INFO']}"
47
+ end
48
+
35
49
  def passthrough(env, error)
36
50
  env[ERROR] = error
37
51
 
@@ -16,10 +16,6 @@ module Masks
16
16
  @lock.synchronize { @issuers.clear }
17
17
  self
18
18
  end
19
-
20
- def urls
21
- @lock.synchronize { @issuers.keys }
22
- end
23
19
  end
24
20
  end
25
21
  end
@@ -1,14 +1,14 @@
1
1
  module Masks
2
2
  module Client
3
3
  class Resource
4
- BEARER = /\ABearer[ \t]+([^\s,]+)[ \t]*\z/i.freeze
4
+ PRESENTED = /\A(Bearer|DPoP)[ \t]+([^\s,]+)[ \t]*\z/i.freeze
5
5
  METADATA_PATH = "/.well-known/oauth-protected-resource".freeze
6
6
  REQUIRED = %w[iss sub exp].freeze
7
7
 
8
8
  attr_reader :issuer, :url, :scopes
9
9
 
10
10
  def initialize(issuer:, url:, scopes: [], metadata_url: nil,
11
- algorithms: Verifier::ALGORITHMS, required: REQUIRED, verifier: nil)
11
+ algorithms: Verifier::ALGORITHMS, required: REQUIRED, verifier: nil, replay: Proof.memory)
12
12
  @issuer = Issuer.resolve(issuer)
13
13
  @url = url.to_s
14
14
  @descriptions = describe(scopes)
@@ -16,10 +16,14 @@ module Masks
16
16
  @metadata_url = metadata_url
17
17
  @required = Array(required)
18
18
  @verifier = verifier || Verifier.new(@issuer, audience: @url, algorithms: algorithms)
19
+ @replay = replay
19
20
  end
20
21
 
21
- def authenticate(authorization, scope: nil, role: nil, organization: nil)
22
- claims = Claims.new(@verifier.verify(token!(authorization), required: @required, typ: Verifier::ACCESS_TOKEN))
22
+ def authenticate(authorization, scope: nil, role: nil, organization: nil, proof: nil, method: nil, url: nil)
23
+ scheme, token = presented!(authorization)
24
+ held = @verifier.verify(token, required: @required, typ: Verifier::ACCESS_TOKEN)
25
+ bound!(held, scheme, token, proof: proof, method: method, url: url)
26
+ claims = Claims.new(held)
23
27
 
24
28
  Array(scope).each { |name| claims.permit!(name) }
25
29
  claims.member!(*Array(role), organization: organization) if role || organization
@@ -30,7 +34,7 @@ module Masks
30
34
  end
31
35
 
32
36
  def token(authorization)
33
- authorization.to_s[BEARER, 1]
37
+ authorization.to_s[PRESENTED, 2]
34
38
  end
35
39
 
36
40
  def metadata_url
@@ -43,7 +47,8 @@ module Masks
43
47
  "authorization_servers" => [ issuer.url ],
44
48
  "scopes_supported" => scopes,
45
49
  "scope_descriptions" => @descriptions,
46
- "bearer_methods_supported" => [ "header" ]
50
+ "bearer_methods_supported" => [ "header" ],
51
+ "dpop_signing_alg_values_supported" => Proof::ALGORITHMS
47
52
  }.reject { |_, value| value.respond_to?(:empty?) && value.empty? }
48
53
  end
49
54
 
@@ -57,7 +62,9 @@ module Masks
57
62
  [ "resource_metadata", metadata_url ]
58
63
  ]
59
64
 
60
- "Bearer " + parameters.filter_map { |name, value|
65
+ scheme = error.dpop? ? "#{Proof::SCHEME} algs=\"#{Proof::ALGORITHMS.join(' ')}\", " : "Bearer "
66
+
67
+ scheme + parameters.filter_map { |name, value|
61
68
  %(#{name}="#{quote(value)}") if value && !value.to_s.empty?
62
69
  }.join(", ")
63
70
  end
@@ -72,8 +79,22 @@ module Masks
72
79
  end.freeze
73
80
  end
74
81
 
75
- def token!(authorization)
76
- token(authorization) || raise(Unauthenticated.new)
82
+ def presented!(authorization)
83
+ match = authorization.to_s.match(PRESENTED) || raise(Unauthenticated.new)
84
+
85
+ [ match[1].casecmp?(Proof::SCHEME) ? :dpop : :bearer, match[2] ]
86
+ end
87
+
88
+ def bound!(claims, scheme, token, proof:, method:, url:)
89
+ jkt = claims.dig("cnf", "jkt")
90
+
91
+ raise Unauthorized.new("that token is not bound to a key, so it is presented as Bearer") if jkt.nil? && scheme == :dpop
92
+ raise Unauthorized.new("that token is bound to a key, so it is presented as DPoP with a proof", dpop: true) if jkt && scheme != :dpop
93
+ return if jkt.nil?
94
+
95
+ Proof.new(proof, method: method, url: url || self.url, replay: @replay).check!(access_token: token, jkt: jkt)
96
+ rescue Proof::Invalid => e
97
+ raise Unauthorized.new(e.message, code: "invalid_dpop_proof", dpop: true)
77
98
  end
78
99
 
79
100
  def origin
@@ -32,10 +32,6 @@ module Masks
32
32
  raise InvalidToken, e.message
33
33
  end
34
34
 
35
- def tenant(token)
36
- verify(token)["tenant"]
37
- end
38
-
39
35
  private
40
36
 
41
37
  def typed!(held, accepted)
data/lib/masks/client.rb CHANGED
@@ -19,6 +19,7 @@ require_relative "client/claims"
19
19
  require_relative "client/introspection"
20
20
  require_relative "client/session"
21
21
  require_relative "client/verifier"
22
+ require_relative "client/proof"
22
23
  require_relative "client/logout"
23
24
  require_relative "client/resource"
24
25
  require_relative "client/rack"
@@ -318,9 +318,15 @@ module Masks
318
318
  path = value.to_s
319
319
 
320
320
  return nil unless path.start_with?("/")
321
- return nil if path.start_with?("//", "/\\")
321
+ return nil if path.match?(/[\x00-\x20\x7f\\]/)
322
322
 
323
- path
323
+ uri = URI.parse(path)
324
+
325
+ return nil if uri.scheme || uri.host || uri.userinfo || !uri.path.start_with?("/") || uri.path.start_with?("//")
326
+
327
+ [ uri.path, uri.query ].compact.join("?")
328
+ rescue URI::InvalidURIError
329
+ nil
324
330
  end
325
331
  end
326
332
  end
@@ -7,7 +7,7 @@ module Masks
7
7
  :after_sign_out, :session_key, :sign_out_of_issuer, :parent_controller,
8
8
  :credentials_path, :authenticate_everything, :delegates
9
9
  attr_writer :issuer, :redirect_uri, :name, :credentials, :store, :forget, :logged_out,
10
- :delegation_redirect_uri, :organization
10
+ :delegation_redirect_uri, :organization, :manages
11
11
 
12
12
  def initialize
13
13
  @scope = Masks::Client::Session::DEFAULT_SCOPE
@@ -89,6 +89,14 @@ module Masks
89
89
  true
90
90
  end
91
91
 
92
+ def manages?(request, identity = nil)
93
+ return false unless @manages.respond_to?(:call)
94
+
95
+ held = @manages.arity == 1 ? @manages.call(request) : @manages.call(request, identity)
96
+
97
+ held ? true : false
98
+ end
99
+
92
100
  def can_forget?
93
101
  @forget.respond_to?(:call) || !@store.respond_to?(:call)
94
102
  end
@@ -106,19 +114,6 @@ module Masks
106
114
  @store.call(request, registration)
107
115
  end
108
116
 
109
- MINIMUM_ISSUER = 1
110
-
111
- def issuer_speaks!(request)
112
- spoken = Masks::Client::Issuer.resolve(issuer_for(request))
113
- .discovery["masks_protocol_version"].to_i
114
-
115
- return true if spoken >= MINIMUM_ISSUER
116
-
117
- raise Unconfigured,
118
- "#{issuer_for(request)} speaks masks protocol #{spoken}, and masks " \
119
- "#{Masks::VERSION} needs at least #{MINIMUM_ISSUER}"
120
- end
121
-
122
117
  def session_for(request)
123
118
  raise Unconfigured, "this app has not shaken hands with #{issuer_for(request)}" unless configured?(request)
124
119
 
@@ -20,18 +20,23 @@ module Masks
20
20
 
21
21
  def masks_authenticate!(scope: nil, role: nil, organization: nil, **)
22
22
  @masks_claims = masks_resource.authenticate(request.authorization, scope: scope, role: role,
23
- organization: organization)
23
+ organization: organization, **masks_proof)
24
24
  rescue Masks::Client::Challenge => e
25
25
  masks_challenge(e)
26
26
  false
27
27
  end
28
28
 
29
29
  def masks_authenticate(scope: nil, role: nil, organization: nil)
30
- masks_resource.authenticate(request.authorization, scope: scope, role: role, organization: organization)
30
+ masks_resource.authenticate(request.authorization, scope: scope, role: role, organization: organization,
31
+ **masks_proof)
31
32
  rescue Masks::Client::Unauthenticated
32
33
  nil
33
34
  end
34
35
 
36
+ def masks_proof
37
+ { proof: request.headers["DPoP"], method: request.request_method, url: "#{request.base_url}#{request.path}" }
38
+ end
39
+
35
40
  def masks_challenge(error)
36
41
  error = Masks::Client::Unauthorized.new(error.to_s) unless error.is_a?(Masks::Client::Challenge)
37
42
 
data/lib/masks/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Masks
2
- VERSION = "0.8.0".freeze
2
+ VERSION = "0.9.2".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: masks
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - the masks authors
@@ -57,6 +57,7 @@ files:
57
57
  - lib/masks/client/issuer.rb
58
58
  - lib/masks/client/logout.rb
59
59
  - lib/masks/client/pkce.rb
60
+ - lib/masks/client/proof.rb
60
61
  - lib/masks/client/rack.rb
61
62
  - lib/masks/client/registration.rb
62
63
  - lib/masks/client/registry.rb