masks 0.10.0 → 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 +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +128 -8
- data/app/controllers/masks/rails/handshakes_controller.rb +8 -0
- data/app/controllers/masks/rails/sessions_controller.rb +2 -1
- data/lib/masks/client/device.rb +47 -0
- data/lib/masks/client/embedded.rb +125 -0
- data/lib/masks/client/enrollment.rb +33 -0
- data/lib/masks/client/errors.rb +6 -0
- data/lib/masks/client/http.rb +89 -15
- data/lib/masks/client/issuer.rb +2 -1
- data/lib/masks/client/key.rb +100 -0
- data/lib/masks/client/nonces.rb +31 -0
- data/lib/masks/client/proof.rb +7 -1
- data/lib/masks/client/rack.rb +8 -0
- data/lib/masks/client/registration.rb +32 -15
- data/lib/masks/client/session.rb +120 -11
- data/lib/masks/client/tokens.rb +4 -0
- data/lib/masks/client/transport.rb +114 -0
- data/lib/masks/client.rb +21 -0
- data/lib/masks/rails/authentication.rb +23 -8
- data/lib/masks/rails/configuration.rb +7 -2
- data/lib/masks/rails/protected_resource.rb +13 -0
- data/lib/masks/version.rb +1 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e082a74bac3486cd6f3291ef29a91dd0ecc325a2f3938465a44722c66e2363b0
|
|
4
|
+
data.tar.gz: 22b5932157ba69c6c8401ab71b54ab0be1c640ae0f5da67f0ffa04c1fa1d15fa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ed991af661f602ac3e32c190575bcb8f03f1f4ad5dce0d4006516ad2587e53efaa15c75b51a480b9ccbb1634f88844cc3a32ceb4c516313f93b928d01298d71f
|
|
7
|
+
data.tar.gz: ef37a84b598962d464d24c68e33db41645ab6e85be67b34d13bae1061f061db0ce2a0180d1b4e82ec77c98a17bfcdbee4812fd2a855783ae2868211e8ff54741
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
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
|
+
|
|
3
33
|
## [0.10.0](https://github.com/masksrb/masks/compare/gem/v0.9.2...gem/v0.10.0) (2026-10-11)
|
|
4
34
|
|
|
5
35
|
|
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/
|
|
25
|
+
`/auth/handshake`. [Rails apps](https://masks.pages.dev/reference/ruby/) walks through the rest.
|
|
26
26
|
|
|
27
27
|
### The handshake
|
|
28
28
|
|
|
@@ -94,7 +94,7 @@ sign-in. When a signed-in person lacks one, the browser goes through masks once
|
|
|
94
94
|
comes back, and the action answers `403` when masks does not grant it. A missing role or organization
|
|
95
95
|
answers `403` at once. `masked.privilege?(:admin)` and `masked.privileges` tell a view what the sign-in
|
|
96
96
|
holds, and `/auth/session` answers `privileges`. See
|
|
97
|
-
[privileges](https://masks.pages.dev/
|
|
97
|
+
[privileges](https://masks.pages.dev/reference/ruby/#privileges).
|
|
98
98
|
|
|
99
99
|
`config.authenticate_everything = true` includes `Authentication` on every controller. Otherwise
|
|
100
100
|
include it where it applies.
|
|
@@ -102,7 +102,7 @@ include it where it applies.
|
|
|
102
102
|
### A single-page app in front of it
|
|
103
103
|
|
|
104
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/
|
|
105
|
+
browser. [`@masks/client`](https://masks.pages.dev/reference/javascript/client/) reads it:
|
|
106
106
|
|
|
107
107
|
```js
|
|
108
108
|
import { createSession } from "@masks/client"
|
|
@@ -225,6 +225,7 @@ a subdomain-per-tenant host needs.
|
|
|
225
225
|
| `sign_out_of_issuer` | make every sign-out an RP-initiated logout |
|
|
226
226
|
| `session_key` | the session key the tokens live under |
|
|
227
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 |
|
|
228
229
|
|
|
229
230
|
## Any Ruby app
|
|
230
231
|
|
|
@@ -249,14 +250,128 @@ Hold `started[:state]`, `started[:nonce]`, and `started[:verifier]` in the sessi
|
|
|
249
250
|
browser to `started[:url]`. `start` sends a nonce only when the scope includes `openid`. Check the
|
|
250
251
|
returned `state` yourself, and compare `identity["nonce"]` with the one you held.
|
|
251
252
|
|
|
252
|
-
`Session` also has `refresh`, `exchange`, `client_credentials`, `
|
|
253
|
-
|
|
254
|
-
`
|
|
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.
|
|
255
364
|
|
|
256
365
|
Discovery and JWKS are cached for five minutes and refetched on an unknown `kid`, so a key rotation
|
|
257
366
|
needs no restart. The cache lives on the `Issuer`, so use `Masks::Client::Issuer.resolve` instead
|
|
258
367
|
of constructing one per request.
|
|
259
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
|
+
|
|
260
375
|
### The handshake
|
|
261
376
|
|
|
262
377
|
The flow that connects a first-party app:
|
|
@@ -297,8 +412,13 @@ claims.tenant.subdomain
|
|
|
297
412
|
accepted only as `Authorization: DPoP` with a proof signed by that key for this method, URL, and
|
|
298
413
|
token, made within the last minute and never seen before. `proof`, `method`, and `url` are what it
|
|
299
414
|
checks the proof against. Seen proofs are kept in the process's memory. A deployment with several
|
|
300
|
-
processes passes `replay:` to `Resource.new`,
|
|
301
|
-
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`.
|
|
302
422
|
|
|
303
423
|
A refusal builds the RFC 6750 challenge carrying `resource_metadata` and the scopes it would have
|
|
304
424
|
accepted:
|
|
@@ -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)
|
|
@@ -73,12 +73,13 @@ module Masks
|
|
|
73
73
|
|
|
74
74
|
def destroy
|
|
75
75
|
everywhere = masks_config.sign_out_of_issuer || params[:everywhere].present?
|
|
76
|
-
upstream = everywhere ? masks_logout_url : nil
|
|
77
76
|
|
|
78
77
|
revoke_refresh_token
|
|
79
78
|
masks_forget
|
|
80
79
|
reset_session
|
|
81
80
|
|
|
81
|
+
upstream = everywhere ? masks_sign_out_upstream : nil
|
|
82
|
+
|
|
82
83
|
if masks_wants_json?
|
|
83
84
|
render json: { "signed_in" => false, "logout_url" => upstream }.compact
|
|
84
85
|
else
|
|
@@ -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
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Masks
|
|
2
|
+
module Client
|
|
3
|
+
class Enrollment
|
|
4
|
+
attr_reader :initial_access_token, :scope, :expires_in, :obtained_at
|
|
5
|
+
|
|
6
|
+
def self.issued(body)
|
|
7
|
+
if body["initial_access_token"].to_s.empty?
|
|
8
|
+
raise Rejected.new(
|
|
9
|
+
body["error"] || "invalid_enrollment_response",
|
|
10
|
+
body["error_description"] || "the enrollment endpoint answered without an initial_access_token"
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
new(body)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(body)
|
|
18
|
+
@initial_access_token = body["initial_access_token"]
|
|
19
|
+
@scope = body["scope"].to_s
|
|
20
|
+
@expires_in = body["expires_in"].to_i
|
|
21
|
+
@obtained_at = Time.now.to_i
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def expires_at
|
|
25
|
+
obtained_at + expires_in
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def scopes
|
|
29
|
+
scope.split
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/masks/client/errors.rb
CHANGED
data/lib/masks/client/http.rb
CHANGED
|
@@ -3,6 +3,7 @@ module Masks
|
|
|
3
3
|
module HTTP
|
|
4
4
|
OPEN_TIMEOUT = 5
|
|
5
5
|
READ_TIMEOUT = 10
|
|
6
|
+
READ_LIMIT = 1 << 20
|
|
6
7
|
|
|
7
8
|
module_function
|
|
8
9
|
|
|
@@ -11,12 +12,22 @@ module Masks
|
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def post_form(url, form, headers = {})
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
encoded(Net::HTTP::Post, url, form, headers)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def delete_form(url, form, headers = {})
|
|
19
|
+
encoded(Net::HTTP::Delete, url, form, headers)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def encoded(verb, url, form, headers)
|
|
23
|
+
request(form_request(verb, url, form, headers))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def form_request(verb, url, form, headers)
|
|
27
|
+
request = verb.new(URI.parse(url.to_s), default_headers.merge(headers))
|
|
16
28
|
request.body = URI.encode_www_form(form)
|
|
17
29
|
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
18
|
-
|
|
19
|
-
self.request(request)
|
|
30
|
+
request
|
|
20
31
|
end
|
|
21
32
|
|
|
22
33
|
def post_json(url, body, headers = {})
|
|
@@ -32,7 +43,10 @@ module Masks
|
|
|
32
43
|
end
|
|
33
44
|
|
|
34
45
|
def fetch(url, headers = {})
|
|
35
|
-
uri = URI.parse(url.to_s)
|
|
46
|
+
uri = checked(URI.parse(url.to_s))
|
|
47
|
+
embedded = Masks::Client.embedded_for(uri)
|
|
48
|
+
|
|
49
|
+
return embedded.deliver(Net::HTTP::Get.new(uri, headers)) if embedded
|
|
36
50
|
|
|
37
51
|
Net::HTTP.start(
|
|
38
52
|
uri.hostname, uri.port,
|
|
@@ -58,26 +72,62 @@ module Masks
|
|
|
58
72
|
end
|
|
59
73
|
|
|
60
74
|
def request(request)
|
|
61
|
-
|
|
75
|
+
parse(deliver(request))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def deliver(request, limit: READ_LIMIT)
|
|
79
|
+
uri = checked(request.uri)
|
|
80
|
+
embedded = Masks::Client.embedded_for(uri)
|
|
62
81
|
|
|
63
|
-
|
|
82
|
+
return embedded.deliver(request, limit: limit) if embedded
|
|
83
|
+
|
|
84
|
+
Net::HTTP.start(
|
|
64
85
|
uri.hostname, uri.port,
|
|
65
86
|
use_ssl: uri.scheme == "https",
|
|
66
87
|
open_timeout: OPEN_TIMEOUT,
|
|
67
88
|
read_timeout: READ_TIMEOUT
|
|
68
|
-
) { |http| http.request(request) }
|
|
69
|
-
|
|
70
|
-
parse(response)
|
|
89
|
+
) { |http| limit ? limited(http, request, limit) : http.request(request) }
|
|
71
90
|
rescue SystemCallError, SocketError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError => e
|
|
72
91
|
raise Unreachable, "#{uri.host} is unreachable (#{e.class})"
|
|
73
92
|
end
|
|
74
93
|
|
|
75
|
-
def
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
94
|
+
def checked(uri)
|
|
95
|
+
return uri if uri.is_a?(URI::HTTPS) && !uri.host.to_s.empty?
|
|
96
|
+
return uri if uri.is_a?(URI::HTTP) && !uri.host.to_s.empty? && (Masks::Client.plain_http? || loopback?(uri.hostname))
|
|
97
|
+
|
|
98
|
+
if uri.is_a?(URI::HTTP)
|
|
99
|
+
raise Insecure, "#{Nonces.origin(uri)} is plain http, which masks allows only for loopback hosts " \
|
|
100
|
+
"unless Masks::Client.plain_http is set"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
raise Error, "#{uri} is not an http or https URL"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def loopback?(host)
|
|
107
|
+
name = host.to_s.downcase
|
|
108
|
+
|
|
109
|
+
return true if name == "localhost" || name.end_with?(".localhost")
|
|
110
|
+
|
|
111
|
+
IPAddr.new(name).loopback?
|
|
112
|
+
rescue IPAddr::Error
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def limited(http, request, limit)
|
|
117
|
+
http.request(request) do |response|
|
|
118
|
+
body = +""
|
|
119
|
+
|
|
120
|
+
response.read_body do |chunk|
|
|
121
|
+
body << chunk
|
|
122
|
+
raise Error, "#{request.uri.host} answered with more than #{limit} bytes" if body.bytesize > limit
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
response.body = body
|
|
80
126
|
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def parse(response)
|
|
130
|
+
body = decoded(response)
|
|
81
131
|
|
|
82
132
|
unless response.is_a?(Net::HTTPSuccess)
|
|
83
133
|
refusal = Unregistered.raised_by?(body) ? Unregistered : Rejected
|
|
@@ -91,6 +141,30 @@ module Masks
|
|
|
91
141
|
|
|
92
142
|
body
|
|
93
143
|
end
|
|
144
|
+
|
|
145
|
+
def decoded(response)
|
|
146
|
+
raw = response.body.to_s
|
|
147
|
+
return {} if raw.strip.empty?
|
|
148
|
+
|
|
149
|
+
held = JSON.parse(raw)
|
|
150
|
+
|
|
151
|
+
return held if held.is_a?(Hash)
|
|
152
|
+
return {} unless response.is_a?(Net::HTTPSuccess)
|
|
153
|
+
|
|
154
|
+
raise Rejected.new("invalid_response", "#{origin(response)} answered with JSON that is not an object",
|
|
155
|
+
status: response.code.to_i)
|
|
156
|
+
rescue JSON::ParserError
|
|
157
|
+
return {} unless response.is_a?(Net::HTTPSuccess)
|
|
158
|
+
|
|
159
|
+
raise Rejected.new("invalid_response", "#{origin(response)} answered with something other than JSON",
|
|
160
|
+
status: response.code.to_i)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def origin(response)
|
|
164
|
+
uri = response.uri
|
|
165
|
+
|
|
166
|
+
uri ? Nonces.origin(uri) : "the server"
|
|
167
|
+
end
|
|
94
168
|
end
|
|
95
169
|
end
|
|
96
170
|
end
|
data/lib/masks/client/issuer.rb
CHANGED
|
@@ -14,13 +14,14 @@ module Masks
|
|
|
14
14
|
Masks::Client.registry[issuer, ttl: ttl]
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
attr_reader :url
|
|
17
|
+
attr_reader :url, :nonces
|
|
18
18
|
|
|
19
19
|
def initialize(url, ttl: TTL)
|
|
20
20
|
@url = self.class.normalize(url)
|
|
21
21
|
@ttl = ttl
|
|
22
22
|
@cache = {}
|
|
23
23
|
@lock = Mutex.new
|
|
24
|
+
@nonces = Nonces.new
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
def discovery
|