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 +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +179 -31
- data/app/controllers/masks/rails/avatars_controller.rb +1 -1
- data/app/controllers/masks/rails/handshakes_controller.rb +9 -1
- data/app/controllers/masks/rails/sessions_controller.rb +5 -3
- 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 +84 -93
- data/lib/masks/rails/configuration.rb +42 -10
- data/lib/masks/rails/masked.rb +126 -0
- data/lib/masks/rails/privileged.rb +29 -0
- data/lib/masks/rails/protected_resource.rb +37 -13
- data/lib/masks/rails.rb +2 -0
- data/lib/masks/version.rb +1 -1
- metadata +9 -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,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/
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
58
|
+
masked!
|
|
58
59
|
|
|
59
60
|
def index
|
|
60
|
-
@who =
|
|
61
|
+
@who = masked.name
|
|
61
62
|
end
|
|
62
63
|
end
|
|
63
64
|
```
|
|
64
65
|
|
|
65
|
-
`
|
|
66
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
`
|
|
103
|
-
`
|
|
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
|
-
|
|
154
|
+
masked! :billing
|
|
124
155
|
end
|
|
125
156
|
```
|
|
126
157
|
|
|
127
|
-
`
|
|
128
|
-
`nil`, and `
|
|
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
|
-
`
|
|
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
|
-
<%
|
|
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
|
-
|
|
143
|
-
|
|
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="<%=
|
|
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
|
-
`
|
|
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`, `
|
|
225
|
-
|
|
226
|
-
`
|
|
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`,
|
|
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:
|
|
@@ -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,
|
|
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:
|
|
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
|