masks 0.11.0 → 0.12.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 +33 -0
- data/README.md +28 -461
- data/app/controllers/masks/rails/handshakes_controller.rb +2 -10
- data/app/controllers/masks/rails/logouts_controller.rb +11 -8
- data/app/controllers/masks/rails/sessions_controller.rb +46 -5
- data/config/routes.rb +1 -0
- data/lib/masks/client/embedded.rb +1 -1
- data/lib/masks/client/handshake.rb +12 -4
- data/lib/masks/client/http.rb +2 -13
- data/lib/masks/client/logout.rb +5 -1
- data/lib/masks/client/resource.rb +1 -1
- data/lib/masks/client/verifier.rb +10 -2
- data/lib/masks/client.rb +0 -4
- data/lib/masks/rails/authentication.rb +98 -33
- data/lib/masks/rails/cache.rb +173 -0
- data/lib/masks/rails/configuration.rb +24 -14
- data/lib/masks/rails/renewal.rb +54 -0
- data/lib/masks/rails/session_cookies.rb +145 -0
- data/lib/masks/rails.rb +5 -4
- data/lib/masks/version.rb +1 -1
- data/lib/masks.rb +0 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 22811283549c975e2550ab8546760831275db1efa68ddfd16ebe1dcfb96278a5
|
|
4
|
+
data.tar.gz: 888decf6ea332f751cb446a4e7535313527141ed64086cbf62acc2787d320a9b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 33275896c9010d94818885f7a0a3f71b83f6782af7384cf29fb70d482b03492b8e662f27d6f748e1d9a05f453f5294dc910ca5d1492395c7bc3e0bc57aaaea6f
|
|
7
|
+
data.tar.gz: 5fb2c1d80a01a0723c3533bc8861cca4c2d0761e9ef5d4ef21f46de41d611835bf9fefe28ac0bab860b65bb0ee166cf8288fbda1a178644f58dc3a850edaea96
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.12.0](https://github.com/masksrb/masks/compare/gem/v0.11.0...gem/v0.12.0) (2026-10-14)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **client:** config.cache shares refreshes, back-channel logouts, and DPoP proofs across a Rails app's processes ([1019324](https://github.com/masksrb/masks/commit/1019324f74d44aa9df62d860082c529f5effda44))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Fixes
|
|
12
|
+
|
|
13
|
+
* **client:** a back-channel logout token must carry exp, and its jti is remembered until two minutes after it expires ([ffbf534](https://github.com/masksrb/masks/commit/ffbf53485583d342f0d6e147077b0c7df05c911f))
|
|
14
|
+
* **client:** a cache outage is told apart from a repeat, so masks and DPoP clients get 503 instead of a refusal ([8884cb6](https://github.com/masksrb/masks/commit/8884cb680e802521a413ea17a96eedfec58cae35))
|
|
15
|
+
* **client:** a page on another site can no longer sign somebody out of a Rails app ([96a084f](https://github.com/masksrb/masks/commit/96a084fe0172b27c1affe9461199335fe2b1bbba))
|
|
16
|
+
* **client:** a Rails sign-in keeps its tokens in its own cookies, split across up to eight, so a large access token no longer overflows the session ([88325fb](https://github.com/masksrb/masks/commit/88325fbee0d54bd90ffe3e7286dfa1553582ff2d))
|
|
17
|
+
* **client:** a sign-in callback this browser did not start no longer repeats the error text in its URL ([60574f2](https://github.com/masksrb/masks/commit/60574f263f11420933956354f3e1cae222cf0418))
|
|
18
|
+
* **client:** HTTP.fetch reads at most 8 MB, so the avatar proxy cannot be made to hold an endless answer ([541c0b5](https://github.com/masksrb/masks/commit/541c0b56d648d8bee2eb7cb82ca5ac061adfc350))
|
|
19
|
+
* **client:** signing out of masks returns to /auth/logout/return, which the handshake registers as the app's post-logout address ([d1dbc05](https://github.com/masksrb/masks/commit/d1dbc05e6bcbe41a4143b1efde901495dd6dbcae))
|
|
20
|
+
* **client:** the challenge for a request with no token carries no error description ([b030959](https://github.com/masksrb/masks/commit/b030959d6fd1b2e32be7f8173b17cde4282d43cc))
|
|
21
|
+
* **engine:** a handshake registers only the post-logout address it names, and keeps its return_to apart, so a new return_to is still asked about ([47a8988](https://github.com/masksrb/masks/commit/47a898874ee2c00f9763a57624cc3cb207a7e48f))
|
|
22
|
+
* **engine:** a refused token is described in masks' own words, not the JWT library's ([dae249d](https://github.com/masksrb/masks/commit/dae249d348c65e83bf96f680746d60ba4642c58d))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Documentation
|
|
26
|
+
|
|
27
|
+
* **client:** the to-do list records how close a Rails session sits to the cookie limit ([deedb14](https://github.com/masksrb/masks/commit/deedb144ba9458904348fa7482303d21691aefbf))
|
|
28
|
+
* comments and pages no longer claim what masks stopped doing ([e0b1ba0](https://github.com/masksrb/masks/commit/e0b1ba0b71ccae8683b605f9442ee16c4afb5dd4))
|
|
29
|
+
* every guide, SDK reference, and README says what masks does now, in fewer words ([7046f86](https://github.com/masksrb/masks/commit/7046f862c774f05407d92cd19779f53bf0b62595))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Refactoring
|
|
33
|
+
|
|
34
|
+
* **client:** HTTP.fetch is a GET through deliver with no read limit, and an embedded request's Host comes from Nonces.origin ([0398e13](https://github.com/masksrb/masks/commit/0398e135ba1a41fd56007709bb35aef900f14e57))
|
|
35
|
+
|
|
3
36
|
## [0.11.0](https://github.com/masksrb/masks/compare/gem/v0.10.0...gem/v0.11.0) (2026-10-13)
|
|
4
37
|
|
|
5
38
|
|
data/README.md
CHANGED
|
@@ -2,54 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
# masks
|
|
4
4
|
|
|
5
|
-
Sign a Ruby or Rails app
|
|
5
|
+
Sign people in to a Ruby or Rails app with a [masks](https://github.com/masksrb/masks) server, and
|
|
6
|
+
accept the tokens it gives out.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
gem "masks"
|
|
9
|
-
```
|
|
8
|
+
The gem has two parts:
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
app pulls in `jwt` and nothing else. See
|
|
14
|
-
[Connecting via SDK](https://masks.pages.dev/guides/connecting-via-sdk/) for what it covers beside
|
|
15
|
-
the browser package.
|
|
10
|
+
- `Masks::Rails`, a Rails engine that adds sign-in to your app. It loads only when Rails does.
|
|
11
|
+
- `Masks::Client`, plain Ruby for any app. It depends on `jwt` and nothing else.
|
|
16
12
|
|
|
17
|
-
##
|
|
13
|
+
## Install
|
|
18
14
|
|
|
19
|
-
```
|
|
20
|
-
|
|
15
|
+
```ruby
|
|
16
|
+
gem "masks"
|
|
21
17
|
```
|
|
22
18
|
|
|
23
|
-
|
|
24
|
-
the file credentials land in. Set `MASKS_ISSUER`, start the app, and open
|
|
25
|
-
`/auth/handshake`. [Rails apps](https://masks.pages.dev/reference/ruby/) walks through the rest.
|
|
19
|
+
The gem needs Ruby 3.2 or later.
|
|
26
20
|
|
|
27
|
-
|
|
21
|
+
## Rails
|
|
28
22
|
|
|
29
|
-
|
|
30
|
-
the issuer's approval screen, and the server redeems the one-time token that comes back, so the
|
|
31
|
-
client secret never passes through the browser. The handshake registers the app for
|
|
32
|
-
`config.resource`, an absolute URL on the app's own origin, and refuses without one. A connected app
|
|
33
|
-
asks before it rotates, because reconnecting takes it offline for a moment.
|
|
23
|
+
1. Run the install generator:
|
|
34
24
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
25
|
+
```sh
|
|
26
|
+
bin/rails generate masks:install --resource https://app.example.com
|
|
27
|
+
```
|
|
38
28
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
```
|
|
29
|
+
2. Set `MASKS_ISSUER` to the masks server and tenant you sign in against.
|
|
30
|
+
3. Start the app and open `/auth/handshake`.
|
|
31
|
+
4. Approve the app on the masks server.
|
|
43
32
|
|
|
44
|
-
|
|
45
|
-
somewhere else with two callables:
|
|
46
|
-
|
|
47
|
-
```ruby
|
|
48
|
-
config.credentials = ->(request) { Tenant.for(request).masks_credentials }
|
|
49
|
-
config.store = ->(request, registration) { Tenant.for(request).connect!(registration) }
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
### Signing in
|
|
33
|
+
Then require a sign-in in a controller:
|
|
53
34
|
|
|
54
35
|
```ruby
|
|
55
36
|
class DashboardController < ApplicationController
|
|
@@ -58,181 +39,13 @@ class DashboardController < ApplicationController
|
|
|
58
39
|
masked!
|
|
59
40
|
|
|
60
41
|
def index
|
|
61
|
-
@
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
```
|
|
65
|
-
|
|
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).
|
|
98
|
-
|
|
99
|
-
`config.authenticate_everything = true` includes `Authentication` on every controller. Otherwise
|
|
100
|
-
include it where it applies.
|
|
101
|
-
|
|
102
|
-
### A single-page app in front of it
|
|
103
|
-
|
|
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/javascript/client/) reads it:
|
|
106
|
-
|
|
107
|
-
```js
|
|
108
|
-
import { createSession } from "@masks/client"
|
|
109
|
-
|
|
110
|
-
const session = createSession()
|
|
111
|
-
const status = await session.status()
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
`status()` answers `signed_in`, `signed_out` with where to sign in, or `handshake_required` with
|
|
115
|
-
where to connect the app first.
|
|
116
|
-
|
|
117
|
-
### Accepting tokens
|
|
118
|
-
|
|
119
|
-
An app that is also a resource server:
|
|
120
|
-
|
|
121
|
-
```ruby
|
|
122
|
-
class ApiController < ApplicationController
|
|
123
|
-
include Masks::Rails::ProtectedResource
|
|
124
|
-
|
|
125
|
-
masked! :read, except: :metadata
|
|
126
|
-
|
|
127
|
-
def metadata
|
|
128
|
-
render json: masks_resource_metadata
|
|
42
|
+
@name = masked.name
|
|
129
43
|
end
|
|
130
44
|
end
|
|
131
45
|
```
|
|
132
46
|
|
|
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
|
|
135
|
-
`resource_metadata`, which points a client at `/.well-known/oauth-protected-resource`. The engine
|
|
136
|
-
does not route that path, so route it to an action like `metadata` above. A DPoP-bound token is
|
|
137
|
-
accepted only with a valid proof, as in [Masks::Client::Resource](#the-resource-server-half).
|
|
138
|
-
|
|
139
|
-
### Organizations
|
|
140
|
-
|
|
141
|
-
A person signs in to an app as a member of one organization when the app asks for the
|
|
142
|
-
`organization` scope. Someone in several organizations picks one, and an app can pick for them by
|
|
143
|
-
naming it, with `/auth?organization=acme` or with `config.organization` for every sign-in.
|
|
144
|
-
|
|
145
|
-
```ruby
|
|
146
|
-
Masks::Rails.configure do |config|
|
|
147
|
-
config.scope = %w[openid profile email offline_access organization]
|
|
148
|
-
config.organization = ->(request) { request.subdomain }
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
class BillingController < ApplicationController
|
|
152
|
-
include Masks::Rails::Authentication
|
|
153
|
-
|
|
154
|
-
masked! :billing
|
|
155
|
-
end
|
|
156
|
-
```
|
|
157
|
-
|
|
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
|
|
160
|
-
profile again, so a promotion or demotion reaches the app within an access token's lifetime.
|
|
161
|
-
`/auth/session` answers the same thing as `organization`.
|
|
162
|
-
|
|
163
|
-
`masked.organizations` lists every organization the person has joined, each with the role held in
|
|
164
|
-
it, and `/auth/session` answers it as `organizations`. Switching sends the person through sign-in
|
|
165
|
-
again naming the other one, which asks nothing more when they are already a member:
|
|
166
|
-
|
|
167
|
-
```erb
|
|
168
|
-
<% masked.organizations.each do |held| %>
|
|
169
|
-
<%= link_to held.name, masks_login_url(organization: held.key, return_to: request.path) %>
|
|
170
|
-
<% end %>
|
|
171
|
-
```
|
|
172
|
-
|
|
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!`.
|
|
176
|
-
|
|
177
|
-
Naming an organization without asking for the scope still holds the sign-in to its members and its
|
|
178
|
-
sign-in policy, and the app learns nothing about the role.
|
|
179
|
-
|
|
180
|
-
### Avatars
|
|
181
|
-
|
|
182
|
-
Every account has three avatars: an uploaded `photo`, an `identicon`, and two-letter `initials`.
|
|
183
|
-
All three arrive on the ID token.
|
|
184
|
-
|
|
185
|
-
```erb
|
|
186
|
-
<img src="<%= masked.avatars.identicon %>?size=64" width="64" height="64" alt="">
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
A photo needs a token, which an `<img>` cannot carry, so the engine proxies it with the token the
|
|
190
|
-
app holds:
|
|
191
|
-
|
|
192
|
-
```erb
|
|
193
|
-
<img src="/auth/avatar" width="64" height="64" alt="">
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
`masked.picture` is the standard OIDC `picture` claim when the account set one, then the photo,
|
|
197
|
-
then the identicon. `issuer.avatar_url(sub, style:, size:)` builds a URL for a subject this app holds
|
|
198
|
-
no token for. Sizes are 32, 64, 128, 256, or 512.
|
|
199
|
-
|
|
200
|
-
### Signing out
|
|
201
|
-
|
|
202
|
-
`DELETE /auth/logout` ends this app's session and revokes its refresh token at the issuer. With `?everywhere=1` the response also carries
|
|
203
|
-
`logout_url`, the issuer's end-session endpoint. `config.sign_out_of_issuer = true` makes that the
|
|
204
|
-
default for every sign-out.
|
|
205
|
-
|
|
206
|
-
### Configuration
|
|
207
|
-
|
|
208
|
-
Every value that varies per request accepts a callable taking the request, which is what
|
|
209
|
-
a subdomain-per-tenant host needs.
|
|
210
|
-
|
|
211
|
-
| | |
|
|
212
|
-
|---|---|
|
|
213
|
-
| `issuer` | the masks issuer this app signs in against |
|
|
214
|
-
| `resource` | this app's own identifier, which the handshake registers and tokens name as their audience |
|
|
215
|
-
| `resource_scopes` | what it accepts, published in its RFC 9728 metadata |
|
|
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 |
|
|
218
|
-
| `organization` | the organization every sign-in names, by key |
|
|
219
|
-
| `credentials` / `store` | where the handshake's result lives |
|
|
220
|
-
| `manages` | who may reconnect or disconnect a connected app |
|
|
221
|
-
| `credentials_path` | where the default store writes, `config/masks.json` by default |
|
|
222
|
-
| `after_sign_in` / `after_sign_out` | paths on this host |
|
|
223
|
-
| `parent_controller` | what the engine's pages inherit, for your layout |
|
|
224
|
-
| `authenticate_everything` | include `Authentication` on every controller |
|
|
225
|
-
| `sign_out_of_issuer` | make every sign-out an RP-initiated logout |
|
|
226
|
-
| `session_key` | the session key the tokens live under |
|
|
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 |
|
|
229
|
-
|
|
230
47
|
## Any Ruby app
|
|
231
48
|
|
|
232
|
-
The Rails engine is built on `Masks::Client`, which any Ruby app can use directly.
|
|
233
|
-
|
|
234
|
-
### The consumer half
|
|
235
|
-
|
|
236
49
|
```ruby
|
|
237
50
|
session = Masks::Client::Session.new(
|
|
238
51
|
issuer: "https://demo.auth.example.com",
|
|
@@ -240,267 +53,21 @@ session = Masks::Client::Session.new(
|
|
|
240
53
|
redirect_uri: "https://app.example.com/auth/callback"
|
|
241
54
|
)
|
|
242
55
|
|
|
243
|
-
started = session.start
|
|
244
|
-
|
|
245
|
-
tokens = session.complete(code: params[:code], verifier: held[:verifier])
|
|
56
|
+
started = session.start
|
|
57
|
+
tokens = session.complete(code: params[:code], verifier: started[:verifier])
|
|
246
58
|
identity = session.identity(tokens)
|
|
247
59
|
```
|
|
248
60
|
|
|
249
|
-
|
|
250
|
-
browser to `started[:url]`.
|
|
251
|
-
|
|
252
|
-
|
|
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.
|
|
364
|
-
|
|
365
|
-
Discovery and JWKS are cached for five minutes and refetched on an unknown `kid`, so a key rotation
|
|
366
|
-
needs no restart. The cache lives on the `Issuer`, so use `Masks::Client::Issuer.resolve` instead
|
|
367
|
-
of constructing one per request.
|
|
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
|
-
|
|
375
|
-
### The handshake
|
|
376
|
-
|
|
377
|
-
The flow that connects a first-party app:
|
|
378
|
-
|
|
379
|
-
```ruby
|
|
380
|
-
handshake = Masks::Client::Handshake.new(
|
|
381
|
-
issuer, name: "xixo", resource: "https://app.example.com/mcp",
|
|
382
|
-
redirect_uris: [ "https://app.example.com/auth/callback" ],
|
|
383
|
-
return_to: "https://app.example.com/"
|
|
384
|
-
)
|
|
385
|
-
|
|
386
|
-
started = handshake.start
|
|
387
|
-
registration = handshake.complete(params, state: held)
|
|
388
|
-
```
|
|
389
|
-
|
|
390
|
-
Hold `started[:state]` and send the browser to `started[:url]`. Before it redeems anything,
|
|
391
|
-
`complete` refuses an `error`, a `state` that does not match this browser, and an `iss` other than
|
|
392
|
-
the issuer it asked.
|
|
393
|
-
|
|
394
|
-
### The resource-server half
|
|
395
|
-
|
|
396
|
-
```ruby
|
|
397
|
-
resource = Masks::Client::Resource.new(
|
|
398
|
-
issuer: "https://demo.auth.example.com",
|
|
399
|
-
url: "https://app.example.com/mcp",
|
|
400
|
-
scopes: { "xixo:catalog:read" => "Search your catalog" }
|
|
401
|
-
)
|
|
402
|
-
|
|
403
|
-
claims = resource.authenticate(
|
|
404
|
-
request.authorization, scope: "xixo:catalog:read",
|
|
405
|
-
proof: request.get_header("HTTP_DPOP"), method: request.request_method, url: request.url
|
|
406
|
-
)
|
|
407
|
-
claims.subject
|
|
408
|
-
claims.tenant.subdomain
|
|
409
|
-
```
|
|
410
|
-
|
|
411
|
-
`authenticate` answers `Claims` or raises. A token bound to a key with DPoP carries `cnf.jkt`, and is
|
|
412
|
-
accepted only as `Authorization: DPoP` with a proof signed by that key for this method, URL, and
|
|
413
|
-
token, made within the last minute and never seen before. `proof`, `method`, and `url` are what it
|
|
414
|
-
checks the proof against. Seen proofs are kept in the process's memory. A deployment with several
|
|
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`.
|
|
422
|
-
|
|
423
|
-
A refusal builds the RFC 6750 challenge carrying `resource_metadata` and the scopes it would have
|
|
424
|
-
accepted:
|
|
425
|
-
|
|
426
|
-
```ruby
|
|
427
|
-
response.headers["WWW-Authenticate"] = resource.challenge(error)
|
|
428
|
-
```
|
|
429
|
-
|
|
430
|
-
`resource.metadata` is the RFC 9728 document to serve at `/.well-known/oauth-protected-resource`.
|
|
431
|
-
Its `scope_descriptions` extension gives masks the sentences its consent screen shows for each
|
|
432
|
-
scope.
|
|
433
|
-
|
|
434
|
-
A Rack middleware does the same below the framework, proof included:
|
|
435
|
-
|
|
436
|
-
```ruby
|
|
437
|
-
use Masks::Client::Rack, resource: resource, scope: "xixo:catalog:read"
|
|
438
|
-
```
|
|
439
|
-
|
|
440
|
-
### Introspection
|
|
441
|
-
|
|
442
|
-
A resource server that only verifies the JWT sees a revocation when the token expires. Introspection
|
|
443
|
-
asks the issuer:
|
|
444
|
-
|
|
445
|
-
```ruby
|
|
446
|
-
found = session.introspect(token)
|
|
447
|
-
found.active? && found.permits?("xixo:catalog:read")
|
|
448
|
-
```
|
|
449
|
-
|
|
450
|
-
`Introspection` is a `Claims` whose `permit!` also raises when the token is not active.
|
|
451
|
-
|
|
452
|
-
### Somebody else's account
|
|
453
|
-
|
|
454
|
-
An app that acts as somebody at Google, Microsoft, or an MCP server while they are away asks masks for
|
|
455
|
-
a [delegation](https://masks.pages.dev/guides/connecting-via-sdk/#delegation). masks keeps and refreshes the
|
|
456
|
-
provider's tokens. The app keeps one secret per connection and trades it for a live access token when
|
|
457
|
-
the last one runs out.
|
|
458
|
-
|
|
459
|
-
```ruby
|
|
460
|
-
delegations = Masks::Client.delegations(
|
|
461
|
-
ENV["MASKS_ISSUER"], client_id: id, client_secret: secret, redirect_uri: "https://app.test/connect/callback"
|
|
462
|
-
)
|
|
463
|
-
|
|
464
|
-
started = delegations.start(provider: "google")
|
|
465
|
-
session[:connecting] = started
|
|
466
|
-
redirect_to started["url"]
|
|
467
|
-
|
|
468
|
-
held = delegations.finish(params: params, started: session.delete(:connecting))
|
|
469
|
-
held.connection
|
|
470
|
-
held.secret
|
|
471
|
-
|
|
472
|
-
upstream = delegations.token(held.secret, connection: held.connection)
|
|
473
|
-
upstream.access_token
|
|
474
|
-
upstream.expires_at
|
|
475
|
-
upstream.secret
|
|
476
|
-
```
|
|
477
|
-
|
|
478
|
-
A Rails app sets `config.delegates = true`, so its handshake asks for `masks:delegate:` and the token
|
|
479
|
-
exchange with it.
|
|
480
|
-
|
|
481
|
-
`upstream.secret` replaces the one you passed in, every time. `Delegations::Refused` means somebody
|
|
482
|
-
has to connect again, and `Delegations::Unavailable` is worth retrying. Both carry `secret` when masks
|
|
483
|
-
had already rotated it, so keep it. Spend a secret from one place at a time, because spending one
|
|
484
|
-
twice revokes it.
|
|
485
|
-
|
|
486
|
-
Tests use the fake, which needs no issuer:
|
|
487
|
-
|
|
488
|
-
```ruby
|
|
489
|
-
require "masks/client/delegations/fake"
|
|
490
|
-
|
|
491
|
-
fake = Masks::Client::Delegations::Fake.new
|
|
492
|
-
started = fake.start(provider: "notion")
|
|
493
|
-
held = fake.finish(params: fake.approve(started), started: started)
|
|
494
|
-
|
|
495
|
-
fake.token(held.secret, connection: held.connection)
|
|
496
|
-
fake.revoke(held.connection)
|
|
497
|
-
fake.unavailable(held.connection)
|
|
498
|
-
```
|
|
61
|
+
Keep `started[:state]`, `started[:nonce]`, and `started[:verifier]` in the session, and send the
|
|
62
|
+
browser to `started[:url]`. When it comes back, check `state` and `identity["nonce"]` against the
|
|
63
|
+
values you kept.
|
|
499
64
|
|
|
500
65
|
## Documentation
|
|
501
66
|
|
|
502
|
-
|
|
503
|
-
|
|
67
|
+
- [Rails apps](https://masks.pages.dev/reference/ruby/) covers the handshake, privileges,
|
|
68
|
+
organizations, accepting tokens, and running several processes.
|
|
69
|
+
- [Masks::Client](https://masks.pages.dev/reference/ruby/client/) and
|
|
70
|
+
[Masks::Rails](https://masks.pages.dev/reference/ruby/rails/) list every class and setting.
|
|
504
71
|
|
|
505
72
|
## License
|
|
506
73
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
module Masks
|
|
2
2
|
module Rails
|
|
3
3
|
class HandshakesController < BaseController
|
|
4
|
-
prepend_before_action :signed_out_upstream, only: :callback
|
|
5
4
|
before_action :require_unconfigured_or_signed_in
|
|
6
5
|
before_action :require_unconfigured_or_manager
|
|
7
6
|
|
|
@@ -30,7 +29,7 @@ module Masks
|
|
|
30
29
|
end
|
|
31
30
|
|
|
32
31
|
def destroy
|
|
33
|
-
return redirect_to(
|
|
32
|
+
return redirect_to(masks_after_sign_out_path) unless masks_signed_in?
|
|
34
33
|
return redirect_to(masks_handshake_path) unless masks_config.can_forget?
|
|
35
34
|
|
|
36
35
|
forget_upstream
|
|
@@ -45,13 +44,6 @@ module Masks
|
|
|
45
44
|
|
|
46
45
|
private
|
|
47
46
|
|
|
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
|
-
|
|
55
47
|
def begin!
|
|
56
48
|
pending = masks_handshakes.open
|
|
57
49
|
started = masks_config.handshake_for(request).start(state: pending.id)
|
|
@@ -75,7 +67,7 @@ module Masks
|
|
|
75
67
|
return unless masks_config.configured?(request)
|
|
76
68
|
return if masks_signed_in?
|
|
77
69
|
|
|
78
|
-
redirect_to
|
|
70
|
+
redirect_to masks_after_sign_out_path
|
|
79
71
|
end
|
|
80
72
|
|
|
81
73
|
def require_unconfigured_or_manager
|
|
@@ -7,25 +7,28 @@ module Masks
|
|
|
7
7
|
return refuse("invalid_request", "logout_token is required") if params[:logout_token].blank?
|
|
8
8
|
|
|
9
9
|
logout = masks_session.logout_token(params[:logout_token])
|
|
10
|
+
issuer = masks_config.issuer_for(request).to_s
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
return refuse("invalid_request", "that logout token was already used") unless masks_cache.first_logout?(issuer, logout)
|
|
13
|
+
|
|
14
|
+
masks_cache.depart!(issuer, logout)
|
|
15
|
+
masks_config.logged_out!(request, logout)
|
|
12
16
|
|
|
13
17
|
response.headers["Cache-Control"] = "no-store"
|
|
14
18
|
|
|
15
|
-
head
|
|
16
|
-
rescue Masks::Client::InvalidToken => e
|
|
17
|
-
refuse("invalid_request", e.message)
|
|
18
|
-
rescue Masks::Rails::Configuration::Unconfigured => e
|
|
19
|
+
head :ok
|
|
20
|
+
rescue Masks::Client::InvalidToken, Masks::Rails::Configuration::Unconfigured => e
|
|
19
21
|
refuse("invalid_request", e.message)
|
|
22
|
+
rescue Masks::Client::Unreachable, Masks::Rails::Cache::Unavailable => e
|
|
23
|
+
refuse("temporarily_unavailable", e.message, status: :service_unavailable)
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
private
|
|
23
27
|
|
|
24
|
-
def refuse(code, description)
|
|
28
|
+
def refuse(code, description, status: :bad_request)
|
|
25
29
|
response.headers["Cache-Control"] = "no-store"
|
|
26
30
|
|
|
27
|
-
render json: { "error" => code, "error_description" => description },
|
|
28
|
-
status: :bad_request
|
|
31
|
+
render json: { "error" => code, "error_description" => description }, status: status
|
|
29
32
|
end
|
|
30
33
|
end
|
|
31
34
|
end
|