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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c0b13e1f370033e4c4512becb39b4d96e4b9d28b39b7f1e773592138626d1151
4
- data.tar.gz: 8540eeecb1b448047198d5da57e5978d0e4ec249138334e7c39d41a719854a99
3
+ metadata.gz: e082a74bac3486cd6f3291ef29a91dd0ecc325a2f3938465a44722c66e2363b0
4
+ data.tar.gz: 22b5932157ba69c6c8401ab71b54ab0be1c640ae0f5da67f0ffa04c1fa1d15fa
5
5
  SHA512:
6
- metadata.gz: fc1b4981446fdc5282e21c00c93a63cbc124d3c675512172a849cdd698f0a52839fce8098fdde6443814131f198eef5829d1dd52416e02bb51ef95f7d5fb634a
7
- data.tar.gz: e40f6519f1c22fefa134217cff31701617551feed13e374f1d801307c2f021cb21eba5b85adc4eb9d28e133e855c4aad4cc05caa4749d34fadd9d7e833493cc1
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/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
 
@@ -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/guides/rails/#privileges).
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/browser/) reads it:
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`, `revoke`, `introspect`, `userinfo`,
253
- and `end_session_url`. It authenticates with `client_secret`, or with `private_key` and `key_id` for
254
- `private_key_jwt`.
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`, any object whose `first?(key, expires_in:)` answers true
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
@@ -4,6 +4,12 @@ module Masks
4
4
 
5
5
  class Unreachable < Error; end
6
6
 
7
+ class Foreign < Error; end
8
+
9
+ class Insecure < Error; end
10
+
11
+ class ReplayUnavailable < Error; end
12
+
7
13
  class Rejected < Error
8
14
  attr_reader :code, :description, :status
9
15
 
@@ -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
- uri = URI.parse(url.to_s)
15
- request = Net::HTTP::Post.new(uri, default_headers.merge(headers))
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
- uri = request.uri
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
- response = Net::HTTP.start(
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 parse(response)
76
- body = begin
77
- JSON.parse(response.body.to_s)
78
- rescue JSON::ParserError
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
@@ -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