masks 0.9.2 → 0.11.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: a915522b23f370321d7eca71a6d4965804d6988e441f6f019b1b1817bab7f0c3
4
- data.tar.gz: 05f68f68ed521f6d378393cadda81bec7b984802f10ab651a5eaa8c9b31d4a7c
3
+ metadata.gz: e082a74bac3486cd6f3291ef29a91dd0ecc325a2f3938465a44722c66e2363b0
4
+ data.tar.gz: 22b5932157ba69c6c8401ab71b54ab0be1c640ae0f5da67f0ffa04c1fa1d15fa
5
5
  SHA512:
6
- metadata.gz: 13467dcc61e51edbb9a7493feb38ccb2cf0f1f42db9a127cb37ccb89c0bf3187de211f928bce1dd854d711f3418a0b504bb3a2b0e645ee8ac7d864b47fd8d4c5
7
- data.tar.gz: 4b0b6abea05f55b54b9280de48c8096a8406fdd82e7dcb575584b56679ccb91fc3af11c2766cda26cee44ae8ace19f384a5714a0e3a81b029bcead957d8ba008
6
+ metadata.gz: ed991af661f602ac3e32c190575bcb8f03f1f4ad5dce0d4006516ad2587e53efaa15c75b51a480b9ccbb1634f88844cc3a32ceb4c516313f93b928d01298d71f
7
+ data.tar.gz: ef37a84b598962d464d24c68e33db41645ab6e85be67b34d13bae1061f061db0ce2a0180d1b4e82ec77c98a17bfcdbee4812fd2a855783ae2868211e8ff54741
data/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.11.0](https://github.com/masksrb/masks/compare/gem/v0.10.0...gem/v0.11.0) (2026-10-13)
4
+
5
+
6
+ ### Features
7
+
8
+ * **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))
9
+ * **client:** a session runs the device flow and polls until somebody approves the code ([cd96836](https://github.com/masksrb/masks/commit/cd96836a9d97049f80b9efcf6eec8335e7650a6a))
10
+ * **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))
11
+ * **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))
12
+ * **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))
13
+ * **client:** Session#unenroll archives a machine the app enrolled and revokes its tokens ([2743890](https://github.com/masksrb/masks/commit/2743890063f1e3818f8acb29048bb549f1b94220))
14
+ * **server:** /manage, the sign-in pages, and the docs share one palette and one font stack ([17efe87](https://github.com/masksrb/masks/commit/17efe87c81c14d7541a37292bf58ba242836eca5))
15
+
16
+
17
+ ### Fixes
18
+
19
+ * **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))
20
+ * **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))
21
+
22
+
23
+ ### Documentation
24
+
25
+ * 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))
26
+ * 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))
27
+
28
+
29
+ ### Refactoring
30
+
31
+ * **server:** /manage derives its surfaces from the shared palette, and --label folds into --brass ([c00d32b](https://github.com/masksrb/masks/commit/c00d32b821cc99b8cbc2903a7a4c0c08aa2770bd))
32
+
33
+ ## [0.10.0](https://github.com/masksrb/masks/compare/gem/v0.9.2...gem/v0.10.0) (2026-10-11)
34
+
35
+
36
+ ### Features
37
+
38
+ * **client:** a sign-in can ask for a step-up scope the app offers, and masks grants it only to people whose scopes cover it ([84b43d8](https://github.com/masksrb/masks/commit/84b43d8c091440a5a9b75721b56f9ced27c9f82f))
39
+ * **client:** masked is the signed-in person in one object, read the same way from a session or a bearer token ([c2ad60c](https://github.com/masksrb/masks/commit/c2ad60cb5d2c795f2bf711e8650af5e1893aa0d2))
40
+ * **client:** masks_require_scope! guards an action with a step-up scope, sending its holder through masks once and refusing everyone else ([fce327e](https://github.com/masksrb/masks/commit/fce327edae9d6166e8bc163d85c4ca8e6548e1eb))
41
+ * **client:** privileges name what an action needs, and masked! checks them for a browser session or a bearer token alike ([97e998a](https://github.com/masksrb/masks/commit/97e998a68bf3365fc5ba72a99ca21cf66c6e500a))
42
+
3
43
  ## [0.9.2](https://github.com/masksrb/masks/compare/gem/v0.9.1...gem/v0.9.2) (2026-10-10)
4
44
 
5
45
 
data/README.md CHANGED
@@ -22,7 +22,7 @@ bin/rails generate masks:install --resource https://app.example.com
22
22
 
23
23
  That mounts the engine at `/auth`, writes `config/initializers/masks.rb`, and gitignores
24
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.
25
+ `/auth/handshake`. [Rails apps](https://masks.pages.dev/reference/ruby/) walks through the rest.
26
26
 
27
27
  ### The handshake
28
28
 
@@ -33,11 +33,12 @@ client secret never passes through the browser. The handshake registers the app
33
33
  asks before it rotates, because reconnecting takes it offline for a moment.
34
34
 
35
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:
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:
38
38
 
39
39
  ```ruby
40
- config.manages = ->(request, identity) { identity["sub"] == ENV["OWNER_SUB"] }
40
+ config.manages = :admin
41
+ config.manages = ->(request, masked) { masked.subject == ENV["OWNER_SUB"] }
41
42
  ```
42
43
 
43
44
  The engine writes what comes back to `config/masks.json`, mode 600. A multi-tenant app keeps it
@@ -54,16 +55,46 @@ config.store = ->(request, registration) { Tenant.for(request).connect!(registra
54
55
  class DashboardController < ApplicationController
55
56
  include Masks::Rails::Authentication
56
57
 
57
- before_action :authenticate_masks!
58
+ masked!
58
59
 
59
60
  def index
60
- @who = masks_identity
61
+ @who = masked.name
61
62
  end
62
63
  end
63
64
  ```
64
65
 
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.
66
+ `masked` is everything about the signed-in request in one object: `subject`, `name`, `nickname`,
67
+ `email`, `picture`, `avatars`, `tenant`, `organization`, `organizations`, `role?`, `scopes`,
68
+ `privileges`, `privilege?`, `access_token`, and `signed_in?`, with `masked["claim"]` for any other
69
+ claim. In a view it is the same helper. Tokens live in the encrypted Rails session and never reach
70
+ JavaScript.
71
+
72
+ ### Privileges
73
+
74
+ A privilege names what an action needs: scopes the person holds in masks, a role in the organization
75
+ they signed in to, or both. A list is shorthand for `{ scope: [...] }`.
76
+
77
+ ```ruby
78
+ Masks::Rails.configure do |config|
79
+ config.privileges = {
80
+ admin: %w[catalog:admin],
81
+ billing: { role: %w[owner billing] },
82
+ acme_admin: { scope: %w[catalog:admin], role: "owner", organization: "acme" }
83
+ }
84
+ end
85
+
86
+ class SettingsController < ApplicationController
87
+ masked!
88
+ masked! :admin, only: %i[create update destroy]
89
+ end
90
+ ```
91
+
92
+ `masked!` with no privilege needs only a sign-in. A privilege's scopes stay out of every ordinary
93
+ sign-in. When a signed-in person lacks one, the browser goes through masks once to ask for it and
94
+ comes back, and the action answers `403` when masks does not grant it. A missing role or organization
95
+ answers `403` at once. `masked.privilege?(:admin)` and `masked.privileges` tell a view what the sign-in
96
+ holds, and `/auth/session` answers `privileges`. See
97
+ [privileges](https://masks.pages.dev/reference/ruby/#privileges).
67
98
 
68
99
  `config.authenticate_everything = true` includes `Authentication` on every controller. Otherwise
69
100
  include it where it applies.
@@ -71,7 +102,7 @@ include it where it applies.
71
102
  ### A single-page app in front of it
72
103
 
73
104
  `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:
105
+ browser. [`@masks/client`](https://masks.pages.dev/reference/javascript/client/) reads it:
75
106
 
76
107
  ```js
77
108
  import { createSession } from "@masks/client"
@@ -91,7 +122,7 @@ An app that is also a resource server:
91
122
  class ApiController < ApplicationController
92
123
  include Masks::Rails::ProtectedResource
93
124
 
94
- masks_protect! scope: "xixo:catalog:read", except: :metadata
125
+ masked! :read, except: :metadata
95
126
 
96
127
  def metadata
97
128
  render json: masks_resource_metadata
@@ -99,8 +130,8 @@ class ApiController < ApplicationController
99
130
  end
100
131
  ```
101
132
 
102
- `masks_protect!` installs the `before_action` and passes `:only` and `:except` through.
103
- `masks_claims` is the verified token. A refusal carries the RFC 6750 challenge with
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
104
135
  `resource_metadata`, which points a client at `/.well-known/oauth-protected-resource`. The engine
105
136
  does not route that path, so route it to an action like `metadata` above. A DPoP-bound token is
106
137
  accepted only with a valid proof, as in [Masks::Client::Resource](#the-resource-server-half).
@@ -120,32 +151,28 @@ end
120
151
  class BillingController < ApplicationController
121
152
  include Masks::Rails::Authentication
122
153
 
123
- masks_members_only! role: %w[owner billing]
154
+ masked! :billing
124
155
  end
125
156
  ```
126
157
 
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
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
129
160
  profile again, so a promotion or demotion reaches the app within an access token's lifetime.
130
161
  `/auth/session` answers the same thing as `organization`.
131
162
 
132
- `masks_organizations` lists every organization the person has joined, each with the role held in
163
+ `masked.organizations` lists every organization the person has joined, each with the role held in
133
164
  it, and `/auth/session` answers it as `organizations`. Switching sends the person through sign-in
134
165
  again naming the other one, which asks nothing more when they are already a member:
135
166
 
136
167
  ```erb
137
- <% masks_organizations.each do |held| %>
168
+ <% masked.organizations.each do |held| %>
138
169
  <%= link_to held.name, masks_login_url(organization: held.key, return_to: request.path) %>
139
170
  <% end %>
140
171
  ```
141
172
 
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: "xixo:catalog:write", role: "owner", organization: "acme"
148
- ```
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!`.
149
176
 
150
177
  Naming an organization without asking for the scope still holds the sign-in to its members and its
151
178
  sign-in policy, and the app learns nothing about the role.
@@ -156,7 +183,7 @@ Every account has three avatars: an uploaded `photo`, an `identicon`, and two-le
156
183
  All three arrive on the ID token.
157
184
 
158
185
  ```erb
159
- <img src="<%= masks_claims.avatars.identicon %>?size=64" width="64" height="64" alt="">
186
+ <img src="<%= masked.avatars.identicon %>?size=64" width="64" height="64" alt="">
160
187
  ```
161
188
 
162
189
  A photo needs a token, which an `<img>` cannot carry, so the engine proxies it with the token the
@@ -166,7 +193,7 @@ app holds:
166
193
  <img src="/auth/avatar" width="64" height="64" alt="">
167
194
  ```
168
195
 
169
- `masks_claims.picture` is the standard OIDC `picture` claim when the account set one, then the photo,
196
+ `masked.picture` is the standard OIDC `picture` claim when the account set one, then the photo,
170
197
  then the identicon. `issuer.avatar_url(sub, style:, size:)` builds a URL for a subject this app holds
171
198
  no token for. Sizes are 32, 64, 128, 256, or 512.
172
199
 
@@ -187,6 +214,7 @@ a subdomain-per-tenant host needs.
187
214
  | `resource` | this app's own identifier, which the handshake registers and tokens name as their audience |
188
215
  | `resource_scopes` | what it accepts, published in its RFC 9728 metadata |
189
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 |
190
218
  | `organization` | the organization every sign-in names, by key |
191
219
  | `credentials` / `store` | where the handshake's result lives |
192
220
  | `manages` | who may reconnect or disconnect a connected app |
@@ -197,6 +225,7 @@ a subdomain-per-tenant host needs.
197
225
  | `sign_out_of_issuer` | make every sign-out an RP-initiated logout |
198
226
  | `session_key` | the session key the tokens live under |
199
227
  | `delegates` / `delegation_redirect_uri` | ask for delegation in the handshake, and where connecting an account returns |
228
+ | `replay` | where seen DPoP proofs are remembered, any object with `first?(key, expires_in:)`, this process's memory by default |
200
229
 
201
230
  ## Any Ruby app
202
231
 
@@ -221,14 +250,128 @@ Hold `started[:state]`, `started[:nonce]`, and `started[:verifier]` in the sessi
221
250
  browser to `started[:url]`. `start` sends a nonce only when the scope includes `openid`. Check the
222
251
  returned `state` yourself, and compare `identity["nonce"]` with the one you held.
223
252
 
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`.
253
+ `Session` also has `refresh`, `exchange`, `client_credentials`, `device_authorization`,
254
+ `await_device`, `enroll`, `unenroll`, `revoke`, `introspect`, `userinfo`, `end_session_url`, and
255
+ `transport`. It authenticates with `client_secret`, or with `private_key` and `key_id` for
256
+ `private_key_jwt`. A `Masks::Client::Key` passed as `private_key` names itself by its thumbprint, so
257
+ `key_id` can be left out.
258
+
259
+ `userinfo` takes `Tokens` or an access token string. A string and a `Bearer` token go as `Bearer`.
260
+ A DPoP-bound token goes as `Authorization: DPoP` with a proof from the session's `dpop:` key, and
261
+ raises `Masks::Client::Error` when the session holds no key.
262
+
263
+ An app that has claimed a namespace enrolls a machine with `enroll`. It returns an
264
+ `Enrollment` whose `initial_access_token` the machine spends once to register its own public key.
265
+
266
+ ```ruby
267
+ enrollment = session.enroll(scope: "xixo:agent", name: "nas", expires_in: 900)
268
+ enrollment.initial_access_token
269
+ ```
270
+
271
+ `unenroll` takes the machine's client ID. masks archives the machine and revokes its tokens, and
272
+ refuses a client the app did not enroll.
273
+
274
+ ```ruby
275
+ session.unenroll(machine_client_id)
276
+ ```
277
+
278
+ ### A machine
279
+
280
+ A machine enrolled by an app makes two P-256 keys: one to sign its client assertions, and one to
281
+ hold its tokens with DPoP. It registers the public half of the first with the app's initial access
282
+ token, and keeps both private halves to itself.
283
+
284
+ ```ruby
285
+ key = Masks::Client::Key.generate
286
+ dpop = Masks::Client::Key.generate
287
+
288
+ registration = Masks::Client::Registration.create(
289
+ "https://demo.auth.example.com", token: initial_access_token,
290
+ name: "nas", jwks: key, token_endpoint_auth_method: "private_key_jwt",
291
+ grant_types: [ "client_credentials" ], dpop_bound_access_tokens: true
292
+ )
293
+
294
+ File.write("machine.json", { "client_id" => registration.client_id, "key" => key.to_h,
295
+ "dpop" => dpop.to_h }.to_json, perm: 0o600)
296
+
297
+ session = registration.session(private_key: key, dpop: dpop)
298
+ tokens = session.client_credentials(scope: "xixo:agent")
299
+ tokens.bound?
300
+ ```
301
+
302
+ `Registration.create` sends `grant_types` and `response_types` only when they are given, and masks
303
+ fills in the rest: an enrolled machine gets `client_credentials`, and an open registration gets
304
+ `authorization_code` and `code`. `jwks:` takes a `Key`, a list of them, or a JWK Set. `Key#jwks` and `Key#public_jwk` hold only the
305
+ public half, and `Key#to_h` holds the private one. `Key.parse` reads a key back from that hash, its
306
+ JSON, or a PEM. A key on another curve, or a public key, raises `ArgumentError`.
307
+
308
+ `Registration.create` with a `token:` posts to `/register` even when discovery publishes no
309
+ `registration_endpoint`, because a tenant that turns open registration off still redeems initial
310
+ access tokens there. `read`, `update`, and `delete` raise `Masks::Client::Foreign` when the
311
+ `registration_client_uri` names an origin other than the issuer's, and send nothing.
312
+
313
+ A `Session` given `dpop:` sends a new DPoP proof with every request to the token endpoint:
314
+ `complete`, `client_credentials`, `refresh`, `exchange`, and `await_device`. A server that answers
315
+ `use_dpop_nonce` with a `DPoP-Nonce` header gets one retry carrying that nonce, and the nonce is
316
+ kept for that origin's next request. A token endpoint that answers a proven request with a `Bearer`
317
+ token is refused with `invalid_dpop_proof`. Revocation, introspection, enrollment, and device
318
+ authorization are sent without a proof.
319
+
320
+ `transport` sends requests to a resource with those tokens. It takes the origins it may send them to
321
+ and raises `Masks::Client::Foreign` for any other URL before it fetches a token. An origin in plain
322
+ `http` follows the same rule as the issuer, and raises `Masks::Client::Insecure` when the transport
323
+ is built. A resource's answers are not limited to 1 MB.
324
+
325
+ ```ruby
326
+ api = session.transport("https://app.example.com", scope: "xixo:agent")
327
+
328
+ response = api.get("https://app.example.com/mcp/feeds?page=2")
329
+ api.post("https://app.example.com/mcp/feeds", body: { "name" => "notes" })
330
+ ```
331
+
332
+ Each request carries `Authorization: DPoP` and a proof naming the method, the URL without its query,
333
+ and the token's hash in `ath`. A resource that answers `401` with `use_dpop_nonce` in
334
+ `WWW-Authenticate` and a new `DPoP-Nonce` is asked once more. The answer is a `Net::HTTPResponse`,
335
+ which the transport returns whatever its status. A `Bearer` token goes as `Bearer` with no proof.
336
+
337
+ Without `tokens:`, the transport asks for `client_credentials` with the `scope` and `resource` it was
338
+ given, and again when they expire. With `tokens:`, it renews them with their refresh token, keeps that
339
+ refresh token when the server does not rotate it, and yields the new tokens so they can be stored:
340
+
341
+ ```ruby
342
+ api = session.transport("https://app.example.com", tokens: held) { |tokens| store(tokens.to_h) }
343
+ ```
344
+
345
+ `Masks::Client::Transport.new(*origins, tokens:, key:)` builds one around tokens you already hold.
346
+
347
+ ### The device flow
348
+
349
+ A device with no browser asks for a code, shows it, and polls until somebody approves it:
350
+
351
+ ```ruby
352
+ device = session.device_authorization(scope: %w[openid offline_access], resource: "https://app.example.com/mcp")
353
+
354
+ puts "Open #{device.verification_uri} and enter #{device.user_code}"
355
+
356
+ tokens = session.await_device(device)
357
+ ```
358
+
359
+ `await_device` waits `interval` seconds between polls, five when the server names none, and five
360
+ seconds longer after each `slow_down`. It keeps polling through `authorization_pending`. It raises
361
+ `Masks::Client::Rejected` with the server's code for `access_denied`, `expired_token`, or any other
362
+ refusal, and raises `expired_token` without polling once `device.expires_at` has passed. A session
363
+ holding a DPoP key proves each poll, and the tokens it returns are held to that key.
227
364
 
228
365
  Discovery and JWKS are cached for five minutes and refetched on an unknown `kid`, so a key rotation
229
366
  needs no restart. The cache lives on the `Issuer`, so use `Masks::Client::Issuer.resolve` instead
230
367
  of constructing one per request.
231
368
 
369
+ Every request the gem makes goes over `https`, or plain `http` to `localhost`, a `*.localhost`
370
+ name, or a loopback address. Any other `http` URL raises `Masks::Client::Insecure` before anything is
371
+ sent. A deployment that reaches its issuer over a private network in plain `http` sets
372
+ `Masks::Client.plain_http = true`. Answers from the issuer are read up to 1 MB, and an answer that
373
+ is not a JSON object raises `Masks::Client::Rejected` with the code `invalid_response`.
374
+
232
375
  ### The handshake
233
376
 
234
377
  The flow that connects a first-party app:
@@ -269,8 +412,13 @@ claims.tenant.subdomain
269
412
  accepted only as `Authorization: DPoP` with a proof signed by that key for this method, URL, and
270
413
  token, made within the last minute and never seen before. `proof`, `method`, and `url` are what it
271
414
  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.
415
+ processes passes `replay:` to `Resource.new`, or sets `config.replay` in a Rails app, to any object
416
+ whose `first?(key, expires_in:)` answers true only once for each key across every process.
417
+
418
+ When that store raises, `authenticate` raises `Masks::Client::ReplayUnavailable`, which is not a
419
+ challenge. The Rack middleware and a Rails controller answer `503` with `temporarily_unavailable`
420
+ and no `WWW-Authenticate` header, so a client keeps a token that is still good. A proof the store
421
+ has already seen is still refused with `401`.
274
422
 
275
423
  A refusal builds the RFC 6750 challenge carrying `resource_metadata` and the scopes it would have
276
424
  accepted:
@@ -22,7 +22,7 @@ module Masks
22
22
  private
23
23
 
24
24
  def photo_url
25
- held = masks_claims.avatars.photo
25
+ held = masked.avatars.photo
26
26
 
27
27
  return nil if held.nil?
28
28
  return nil unless held.start_with?("#{masks_config.issuer_for(request)}/")
@@ -1,6 +1,7 @@
1
1
  module Masks
2
2
  module Rails
3
3
  class HandshakesController < BaseController
4
+ prepend_before_action :signed_out_upstream, only: :callback
4
5
  before_action :require_unconfigured_or_signed_in
5
6
  before_action :require_unconfigured_or_manager
6
7
 
@@ -44,6 +45,13 @@ module Masks
44
45
 
45
46
  private
46
47
 
48
+ def signed_out_upstream
49
+ return if params[:initial_access_token].present? || params[:error].present?
50
+ return stale if masks_farewells.claim(params[:state].to_s).nil?
51
+
52
+ redirect_to masks_after_sign_out_path
53
+ end
54
+
47
55
  def begin!
48
56
  pending = masks_handshakes.open
49
57
  started = masks_config.handshake_for(request).start(state: pending.id)
@@ -72,7 +80,7 @@ module Masks
72
80
 
73
81
  def require_unconfigured_or_manager
74
82
  return unless masks_config.configured?(request)
75
- return if masks_config.manages?(request, masks_identity)
83
+ return if masks_config.manages?(request, masked)
76
84
 
77
85
  @code = "forbidden"
78
86
  @description = "only somebody this app lets manage its connection can reconnect or disconnect it"
@@ -8,7 +8,7 @@ module Masks
8
8
  if masks_configured? && (masks_signed_in? || (masks_tokens && masks_refresh!))
9
9
  response.headers["Cache-Control"] = "no-store"
10
10
 
11
- render json: masks_account
11
+ render json: masked.to_h
12
12
  else
13
13
  masks_forget
14
14
  masks_refuse_json
@@ -25,6 +25,7 @@ module Masks
25
25
  started = masks_session.start(
26
26
  state: pending.id,
27
27
  resource: masks_config.resource_for(request),
28
+ scope: masks_config.scope_for(params[:privilege]),
28
29
  organization: Masks::Client::Session.organization_key(params[:organization]) ||
29
30
  masks_config.organization_for(request)
30
31
  )
@@ -72,12 +73,13 @@ module Masks
72
73
 
73
74
  def destroy
74
75
  everywhere = masks_config.sign_out_of_issuer || params[:everywhere].present?
75
- upstream = everywhere ? masks_logout_url : nil
76
76
 
77
77
  revoke_refresh_token
78
78
  masks_forget
79
79
  reset_session
80
80
 
81
+ upstream = everywhere ? masks_sign_out_upstream : nil
82
+
81
83
  if masks_wants_json?
82
84
  render json: { "signed_in" => false, "logout_url" => upstream }.compact
83
85
  else
@@ -88,7 +90,7 @@ module Masks
88
90
  private
89
91
 
90
92
  def renew_session
91
- carried = session.to_hash.slice(REQUESTS, HANDSHAKES)
93
+ carried = session.to_hash.slice(REQUESTS, HANDSHAKES, Masks::Rails::Authentication::STEPPED_UP)
92
94
 
93
95
  reset_session
94
96
  carried.each { |key, value| session[key] = value }
@@ -0,0 +1,47 @@
1
+ module Masks
2
+ module Client
3
+ class Device
4
+ GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code".freeze
5
+ INTERVAL = 5
6
+ REQUIRED = %w[device_code user_code verification_uri].freeze
7
+
8
+ attr_reader :device_code, :user_code, :verification_uri, :verification_uri_complete, :expires_in, :interval,
9
+ :obtained_at
10
+
11
+ def self.issued(body)
12
+ missing = REQUIRED.find { |field| body[field].to_s.empty? }
13
+
14
+ if missing
15
+ raise Rejected.new(
16
+ body["error"] || "invalid_device_authorization_response",
17
+ body["error_description"] || "the device authorization endpoint answered without a #{missing}"
18
+ )
19
+ end
20
+
21
+ new(body)
22
+ end
23
+
24
+ def initialize(body)
25
+ @device_code = body["device_code"]
26
+ @user_code = body["user_code"]
27
+ @verification_uri = body["verification_uri"]
28
+ @verification_uri_complete = body["verification_uri_complete"]
29
+ @expires_in = body["expires_in"].to_i
30
+ @interval = body["interval"].to_i
31
+ @obtained_at = Time.now.to_i
32
+ end
33
+
34
+ def expires_at
35
+ obtained_at + expires_in
36
+ end
37
+
38
+ def expired?
39
+ Time.now.to_i > expires_at
40
+ end
41
+
42
+ def inspect
43
+ "#<#{self.class.name} #{user_code} for #{verification_uri}>"
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,125 @@
1
+ require "stringio"
2
+
3
+ module Masks
4
+ module Client
5
+ class Embedded
6
+ LOOPBACK = "127.0.0.1".freeze
7
+ DROPPED = %w[host accept-encoding connection content-length].freeze
8
+
9
+ attr_reader :app
10
+
11
+ def initialize(app, &serves)
12
+ raise ArgumentError, "an embedded issuer needs the app that serves it" if app.nil?
13
+ raise ArgumentError, "an embedded issuer needs a block naming the URLs it serves" if serves.nil?
14
+
15
+ @app = app
16
+ @serves = serves
17
+ end
18
+
19
+ def serves?(uri)
20
+ @serves.call(uri) ? true : false
21
+ end
22
+
23
+ def deliver(request, limit: nil)
24
+ uri = request.uri
25
+ status, headers, body = answer(environment(request, uri), uri, limit)
26
+
27
+ parsed(request, raw(status, headers, body))
28
+ end
29
+
30
+ def inspect
31
+ "#<#{self.class.name} #{app.inspect}>"
32
+ end
33
+
34
+ private
35
+
36
+ def answer(env, uri, limit)
37
+ worker = Thread.new { collected(*app.call(env), uri, limit) }
38
+ worker.report_on_exception = false
39
+
40
+ unless worker.join(HTTP::READ_TIMEOUT)
41
+ worker.kill
42
+ raise Unreachable, "#{uri.host} did not answer within #{HTTP::READ_TIMEOUT} seconds"
43
+ end
44
+
45
+ worker.value
46
+ rescue Error
47
+ raise
48
+ rescue StandardError => e
49
+ raise Unreachable, "#{uri.host} failed inside this process (#{e.class})"
50
+ end
51
+
52
+ def collected(status, headers, body, uri, limit)
53
+ held = "".b
54
+
55
+ body.each do |chunk|
56
+ held << chunk.to_s.b
57
+ raise Error, "#{uri.host} answered with more than #{limit} bytes" if limit && held.bytesize > limit
58
+ end
59
+
60
+ [ status.to_i, headers, held ]
61
+ ensure
62
+ body.close if body.respond_to?(:close)
63
+ end
64
+
65
+ def environment(request, uri)
66
+ input = request.body.to_s.b
67
+
68
+ env = {
69
+ "REQUEST_METHOD" => request.method,
70
+ "SCRIPT_NAME" => "",
71
+ "PATH_INFO" => uri.path.to_s.empty? ? "/" : uri.path,
72
+ "QUERY_STRING" => uri.query.to_s,
73
+ "SERVER_NAME" => uri.host,
74
+ "SERVER_PORT" => uri.port.to_s,
75
+ "SERVER_PROTOCOL" => "HTTP/1.1",
76
+ "REMOTE_ADDR" => LOOPBACK,
77
+ "HTTP_HOST" => uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}",
78
+ "HTTPS" => uri.scheme == "https" ? "on" : "off",
79
+ "rack.url_scheme" => uri.scheme,
80
+ "rack.input" => StringIO.new(input),
81
+ "rack.errors" => $stderr
82
+ }
83
+
84
+ env["CONTENT_LENGTH"] = input.bytesize.to_s unless input.empty?
85
+
86
+ request.each_header do |name, value|
87
+ next if DROPPED.include?(name)
88
+
89
+ key = name.upcase.tr("-", "_")
90
+ env[key == "CONTENT_TYPE" ? key : "HTTP_#{key}"] = value
91
+ end
92
+
93
+ env
94
+ end
95
+
96
+ def raw(status, headers, body)
97
+ lines = [ "HTTP/1.1 #{status} #{reason(status)}" ]
98
+
99
+ headers.each do |name, value|
100
+ next if %w[content-length transfer-encoding].include?(name.to_s.downcase)
101
+
102
+ Array(value).flat_map { |held| held.to_s.split(/\r?\n/) }.each { |held| lines << "#{name}: #{held}" }
103
+ end
104
+
105
+ lines << "content-length: #{body.bytesize}"
106
+
107
+ "#{lines.join("\r\n")}\r\n\r\n".b + body
108
+ end
109
+
110
+ def parsed(request, raw)
111
+ io = Net::BufferedIO.new(StringIO.new(raw))
112
+ response = Net::HTTPResponse.read_new(io)
113
+ response.uri = request.uri
114
+ response.reading_body(io, request.response_body_permitted?) { response.body }
115
+ response
116
+ end
117
+
118
+ def reason(status)
119
+ named = Net::HTTPResponse::CODE_TO_OBJ[status.to_s]
120
+
121
+ named ? named.name.delete_prefix("Net::HTTP").gsub(/(?<=[a-z])(?=[A-Z])/, " ") : "Unknown"
122
+ end
123
+ end
124
+ end
125
+ end